diff --git a/docs/superpowers/plans/2026-06-29-3d-radar-volume-ingest.md b/docs/superpowers/plans/2026-06-29-3d-radar-volume-ingest.md new file mode 100644 index 0000000..3d39404 --- /dev/null +++ b/docs/superpowers/plans/2026-06-29-3d-radar-volume-ingest.md @@ -0,0 +1,1052 @@ +# 三维雷达体接入(规范化格式)Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 让一条"规范化 `.head/.data/.cor`"三维雷达测线在桌面 app 里**登记成 `dd_radar_3d` DS**并渲染成体,复用现成切片/异常工具。 + +**Architecture:** 在稳定的 `core::BuiltI16` 汇聚点接入新格式——新写"规范化 reader + bridge"产出 `BuiltI16` → `builtI16ToVolumeGrid` → `createRadarVolumeGrid`;上层走 **DS 优先**:`registerRadarDataset` 把体以 `ddCode=dd_radar_3d` 登记进 `volumes_`(运行期路由只认 `volumes_` 成员,故 `isVolumeDataset`/`loadVolume`/`addDatasetAsync` 零改动)→ 勾选 → 渲染。先把现有 `Gpr3dvVolumeBridge` 的"扫值域→量化→通道插值→填体→spacing"抽成共享 helper(消 DRY),Impulse 与规范化两路同调。 + +**Tech Stack:** C++17、Qt6、VTK 9.6.2、GoogleTest/CTest、CMake(Ninja)、vcpkg。 + +## Global Constraints + +- C++17;命名空间 `geopro::io::gpr` / `geopro::data`;类型 `PascalCase`、函数 `camelCase`、成员 `snake_`、常量 `kPascalCase`。 +- 体轴映射固定:**X=道(nx=K) / Y=通道(ny=M) / Z=采样(nz=N)**。 +- 数据体磁盘主序固定:**position-major**,扁平索引 `((t*M + c)*N + s)`(t=道, c=通道, s=采样;采样最快、道最慢)。⚠️ 与体内存布局(`((k*ny+j)*nx+i)`,道最快)相反 → **逐体素散射填值,严禁 memcpy/整块拷贝**。 +- 量化:复用 `geopro::core::Quant`(`scale=(vmax-vmin)/64000`、`offset=中点`);GPR 体稠密无空洞 → **不置 `kBlank`**;不特判 `-32768`(饱和真实值,经 `toQ` 落普通区间)。 +- `BITS` 真源 = `.head` 的 `BITS` 字段(P0 仅支持 16-bit;32-bit 抛 `not implemented`)。`ENDIAN_TYPE` 1=小端/2=大端。 +- 通道插值复用纯函数 `geopro::io::gpr::planChannelInterpolation(offsets, targetDy)`(默认 `targetDy=0.025`),通道横向偏移取自 `.head` 的 `CH_X_OFFSETS`,**绝不跨线**。 +- 内存:app 体是 `std::vector`(int16 的 4×);单线默认 `coarse=4`(峰值 <0.5GB)。多线/全分辨率不在本计划(走 P2 核外)。 +- 构建:`build.bat app`(app)/ `cmake --build build/release --target geopro_tests`(测试);**重链前关掉在跑的 app/exe**(LNK1104)。**别删** `build/release`。 +- 提交:Conventional Commits(`feat:`/`refactor:`/`test:`),分支 `feat/3d-radar-volume-ingest`(已存在)。 +- 失败排查:桌面日志 `%LOCALAPPDATA%/Geomative/Geopro3/logs/geopro_*.log`。 + +--- + +### Task 1: 抽出共享建体 helper `assembleRadarVolume`(消 HIGH-1 DRY) + +把 `Gpr3dvVolumeBridge.cpp:132-197` 的"扫值域→Quant→通道插值→逐体素填→spacing"抽成格式无关的共享函数,Impulse 路改为调用它,行为不变(现有 gtest 兜底)。 + +**Files:** +- Create: `src/io/gpr/RadarVolumeAssembler.hpp` +- Create: `src/io/gpr/RadarVolumeAssembler.cpp` +- Modify: `src/io/gpr/Gpr3dvVolumeBridge.cpp:99-197`(dims 之后改为组 `RadarCubeDesc` + sampler 调 helper) +- Modify: `src/io/CMakeLists.txt`(加 `RadarVolumeAssembler.cpp`) +- Test: `tests/io/gpr/test_radar_volume_assembler.cpp` +- Modify: `tests/CMakeLists.txt`(加新测试源) + +**Interfaces:** +- Produces: + ```cpp + namespace geopro::io::gpr { + struct RadarCubeDesc { + int channels = 0; // M + int traces = 0; // K(源道数, 下采样前) + int samples = 0; // N + std::vector chXOffsets; // 每通道横向偏移(米); size==channels 时启用通道插值 + double dxBase = 1.0; // 道距(米) → dx = dxBase*stride + double dyWhenNotInterpolated = 1.0;// 未插值时 Y 间距(米) + double dz = 1.0; // 深度采样间距(米) + }; + using CubeSampler = std::function; + // 扫值域→Quant→通道插值(planChannelInterpolation)→逐体素填→spacing。轴 X=道/Y=通道/Z=样本。 + // coarse≥1 沿道下采样(nxOut=ceil(K/coarse), dx×coarse);targetDy>0 启用通道插值。GPR 稠密不置 kBlank。 + geopro::core::BuiltI16 assembleRadarVolume(const RadarCubeDesc& desc, + const CubeSampler& sample, + int coarse, double targetDy); + } + ``` +- Consumes: `geopro::core::{BuiltI16,Quant,ScalarVolumeI16}`、`planChannelInterpolation`。 + +- [ ] **Step 1: 写失败测试** + +`tests/io/gpr/test_radar_volume_assembler.cpp`: +```cpp +#include +#include "core/algo/GprVolumeBuilder.hpp" +#include "io/gpr/RadarVolumeAssembler.hpp" + +using geopro::io::gpr::RadarCubeDesc; +using geopro::io::gpr::assembleRadarVolume; + +// 2 道 × 3 通道 × 4 采样,值 = 100*c + 10*t + s。不插值(chXOffsets 空)、coarse=1。 +TEST(RadarVolumeAssembler, AxisMapAndQuantRoundTrip) { + RadarCubeDesc d; + d.channels = 3; d.traces = 2; d.samples = 4; + d.dxBase = 0.1; d.dyWhenNotInterpolated = 0.5; d.dz = 0.05; + auto sampler = [](int c, int t, int s) { return 100.0 * c + 10.0 * t + s; }; + + const geopro::core::BuiltI16 b = assembleRadarVolume(d, sampler, /*coarse=*/1, /*targetDy=*/0.0); + + EXPECT_EQ(b.vol.nx(), 2); // 道 + EXPECT_EQ(b.vol.ny(), 3); // 通道(未插值=原通道数) + EXPECT_EQ(b.vol.nz(), 4); // 采样 + EXPECT_DOUBLE_EQ(b.spacing[0], 0.1); + EXPECT_DOUBLE_EQ(b.spacing[1], 0.5); + EXPECT_DOUBLE_EQ(b.spacing[2], 0.05); + EXPECT_NEAR(b.vminPhys, 0.0, 1e-9); // c0,t0,s0 + EXPECT_NEAR(b.vmaxPhys, 213.0, 1e-9); // c2,t1,s3 = 200+10+3 + // 反量化对位:体素(道 t=1, 通道 c=2, 采样 s=3) 应≈213(量化误差内)。 + const double recon = b.quant.toPhys(b.vol.at(1, 2, 3)); + EXPECT_NEAR(recon, 213.0, b.quant.scale); +} + +// coarse=2:4 道 → nxOut=2,dx×2。 +TEST(RadarVolumeAssembler, CoarseDownsamplesTracesAndScalesDx) { + RadarCubeDesc d; + d.channels = 1; d.traces = 4; d.samples = 2; d.dxBase = 0.1; + auto sampler = [](int, int t, int s) { return 10.0 * t + s; }; + const geopro::core::BuiltI16 b = assembleRadarVolume(d, sampler, /*coarse=*/2, 0.0); + EXPECT_EQ(b.vol.nx(), 2); + EXPECT_DOUBLE_EQ(b.spacing[0], 0.2); + EXPECT_NEAR(b.quant.toPhys(b.vol.at(1, 0, 0)), 20.0, b.quant.scale); // 输出道1 = 源道2 +} +``` + +- [ ] **Step 2: 跑测试确认失败** + +Run: `cmake --build build/release --target geopro_tests && ctest --test-dir build/release -R RadarVolumeAssembler -V` +Expected: 编译失败(`RadarVolumeAssembler.hpp` 不存在)。 + +- [ ] **Step 3: 写 helper 实现** + +`src/io/gpr/RadarVolumeAssembler.hpp`: +```cpp +#ifndef GEOPRO_IO_GPR_RADARVOLUMEASSEMBLER_HPP +#define GEOPRO_IO_GPR_RADARVOLUMEASSEMBLER_HPP +#include +#include +#include "core/algo/GprVolumeBuilder.hpp" +namespace geopro::io::gpr { +struct RadarCubeDesc { + int channels = 0; int traces = 0; int samples = 0; + std::vector chXOffsets; + double dxBase = 1.0; double dyWhenNotInterpolated = 1.0; double dz = 1.0; +}; +using CubeSampler = std::function; +geopro::core::BuiltI16 assembleRadarVolume(const RadarCubeDesc& desc, + const CubeSampler& sample, + int coarse, double targetDy); +} // namespace geopro::io::gpr +#endif +``` +`src/io/gpr/RadarVolumeAssembler.cpp`(搬 `Gpr3dvVolumeBridge.cpp:115-195` 的逻辑,数据访问换成 `sample(c,t,s)`): +```cpp +#include "io/gpr/RadarVolumeAssembler.hpp" +#include +#include +#include +#include "core/model/ScalarVolumeI16.hpp" +#include "io/gpr/GprGeometry.hpp" // planChannelInterpolation, ChannelInterpRow +namespace geopro::io::gpr { + +geopro::core::BuiltI16 assembleRadarVolume(const RadarCubeDesc& d, + const CubeSampler& sample, + int coarse, double targetDy) { + if (d.channels <= 0 || d.traces <= 0 || d.samples <= 0) + throw std::runtime_error("assembleRadarVolume: 维度为空"); + const int stride = coarse > 1 ? coarse : 1; + const int nxOut = (d.traces + stride - 1) / stride; + const int nz = d.samples; + + // 通道插值方案(读 chXOffsets 规则化到 targetDy);退路=逐通道 identity。 + std::vector rows; + bool interpolated = false; + if (static_cast(d.chXOffsets.size()) == d.channels && targetDy > 0.0) { + rows = planChannelInterpolation(d.chXOffsets, targetDy); + interpolated = (static_cast(rows.size()) != d.channels); + } + if (rows.empty()) + for (int c = 0; c < d.channels; ++c) rows.push_back({c, c, 0.0}); + const int ny = static_cast(rows.size()); + + // 扫值域 → Quant(中点 offset, 64000 裕度)。 + double vmin = std::numeric_limits::infinity(); + double vmax = -std::numeric_limits::infinity(); + for (int c = 0; c < d.channels; ++c) + for (int t = 0; t < d.traces; ++t) + for (int s = 0; s < d.samples; ++s) { + const double v = sample(c, t, s); + if (v < vmin) vmin = v; + if (v > vmax) vmax = v; + } + if (!(vmin <= vmax)) { vmin = 0.0; vmax = 0.0; } + geopro::core::Quant quant; + quant.scale = (vmax > vmin) ? (vmax - vmin) / 64000.0 : 1.0; + quant.offset = 0.5 * (vmin + vmax); + + // 逐(输出行 j, 输出道 to, 采样 s)填,散射写入(绝不 memcpy)。 + geopro::core::BuiltI16 built; + built.vol = geopro::core::ScalarVolumeI16(nxOut, ny, nz); + for (int j = 0; j < ny; ++j) { + const int a = rows[j].a, b = rows[j].b; + const double wb = rows[j].wb, wa = 1.0 - wb; + for (int to = 0; to < nxOut; ++to) { + const int t = to * stride; + for (int s = 0; s < nz; ++s) { + const double va = sample(a, t, s); + const double vb = (b == a) ? va : sample(b, t, s); + built.vol.at(to, j, s) = quant.toQ(wa * va + wb * vb); + } + } + } + + const double dy = interpolated ? targetDy : d.dyWhenNotInterpolated; + built.quant = quant; + built.origin = {0.0, 0.0, 0.0}; + built.spacing = {d.dxBase * stride, dy, d.dz}; + built.vminPhys = vmin; + built.vmaxPhys = vmax; + return built; +} +} // namespace geopro::io::gpr +``` +在 `src/io/CMakeLists.txt` 现有 gpr 源清单里加 `gpr/RadarVolumeAssembler.cpp`(紧挨 `gpr/Gpr3dvVolumeBridge.cpp`)。在 `tests/CMakeLists.txt` 现有 `geopro_tests` 源清单里加 `io/gpr/test_radar_volume_assembler.cpp`(紧挨 `io/gpr/test_gpr3dv_volume_bridge.cpp`)。 + +- [ ] **Step 4: 跑测试确认通过** + +Run: `cmake --build build/release --target geopro_tests && ctest --test-dir build/release -R RadarVolumeAssembler -V` +Expected: 2 个用例 PASS。 + +- [ ] **Step 5: Impulse 路改调 helper(保持现有 gtest 绿)** + +把 `Gpr3dvVolumeBridge.cpp` 内联的"扫值域+Quant+填体+spacing"(约 :132-195)替换为:组 `RadarCubeDesc desc{channels, traces, samples, latOff, h.distanceInc>1e-9?h.distanceInc:1.0, channelSpacingY(h,channels), depthSpacingZ(h)}` + `CubeSampler sample = [&processed](int c,int t,int s){ const auto& ch=processed.volumeData[c]; if(t>=(int)ch.size()) return 0.0; const auto& tr=ch[t]; return s<(int)tr.size()? (double)tr[s] : 0.0; }`,调 `const auto built = assembleRadarVolume(desc, sample, coarse, targetDy);`,再回填 `metricsOut`(nx/ny/nz 取 `built.vol.*()`,dx/dy/dz 取 `built.spacing[*]`,vmin/vmax 取 `built.vminPhys/vmaxPhys`,before/after/load/pipeline 仍用原 Impulse 统计)。保留 `latOff`/`channelSpacingY`/`depthSpacingZ`/`meanAbsAmplitude` 不动。`#include "io/gpr/RadarVolumeAssembler.hpp"`。 + +- [ ] **Step 6: 跑现有 Impulse 测试确认无回归** + +Run: `cmake --build build/release --target geopro_tests && ctest --test-dir build/release -R "Gpr3dv|RadarVolumeAssembler" -V` +Expected: `test_gpr3dv_volume_bridge` 全部仍 PASS + 新测试 PASS(行为不变)。 + +- [ ] **Step 7: Commit** + +```bash +git add src/io/gpr/RadarVolumeAssembler.hpp src/io/gpr/RadarVolumeAssembler.cpp \ + src/io/gpr/Gpr3dvVolumeBridge.cpp src/io/CMakeLists.txt \ + tests/io/gpr/test_radar_volume_assembler.cpp tests/CMakeLists.txt +git commit -m "refactor(gpr): 抽出共享 assembleRadarVolume,Impulse 路改调(消填体 DRY)" +``` + +--- + +### Task 2: 规范化 `.head` 解析器 + +**Files:** +- Create: `src/io/gpr/NormalizedRadarReader.hpp` +- Create: `src/io/gpr/NormalizedRadarReader.cpp` +- Modify: `src/io/CMakeLists.txt`(加 `NormalizedRadarReader.cpp`) +- Test: `tests/io/gpr/test_normalized_radar_reader.cpp` +- Modify: `tests/CMakeLists.txt`(加新测试源) + +**Interfaces:** +- Produces: + ```cpp + namespace geopro::io::gpr { + struct RadarHeader { + int samples = 0; // N (SAMPLES) + int channels = 0; // M (NUMBER_OF_CH) + long lastTrace = 0; // 总扫描数=K*M (LAST_TRACE) + int traces = 0; // K = lastTrace/channels + int bits = 16; // 8/16/32 (BITS) + int endianType = 1; // 1 小端/2 大端 (ENDIAN_TYPE) + double distanceInterval = 1.0; // 道距 m (DISTANCE_INTERVAL) + double timeWindowNs = 0.0; // 时窗 ns (TIMEWINDOW) + double dielectric = 0.0; // 介电常数 (DIELECTRIC, 0=未知) + std::vector chXOffsets; // 通道横向偏移 m (CH_X_OFFSETS) + }; + // 解析 KEY:VALUE 文本头。缺 SAMPLES/NUMBER_OF_CH/LAST_TRACE 任一 → std::runtime_error。 + // traces = lastTrace/channels(不整除抛错)。 + RadarHeader parseRadarHead(const std::string& headText); + // 由 dielectric 求波速(m/ns): >0 时 0.2998/sqrt(eps),否则 0.1(默认)。 + double waveVelocityMperNs(const RadarHeader& h); + // 深度采样间距(米): timeWindowNs/(samples-1) × 波速/2。samples<=1 → 0。 + double depthSpacingZ(const RadarHeader& h); + } + ``` + +- [ ] **Step 1: 写失败测试** + +`tests/io/gpr/test_normalized_radar_reader.cpp`: +```cpp +#include +#include "io/gpr/NormalizedRadarReader.hpp" +using namespace geopro::io::gpr; + +TEST(NormalizedRadarHead, ParsesCoreFieldsAndDerivesTraces) { + const std::string head = + "SAMPLES:516\nNUMBER_OF_CH:16\nLAST_TRACE:60448\nBITS:16\nENDIAN_TYPE:1\n" + "DISTANCE_INTERVAL:0.099194\nTIMEWINDOW:96.419553\nDIELECTRIC:\n" + "CH_X_OFFSETS:0.080 0.160 0.240 0.320 0.400 0.480 0.560 0.640 0.720 0.800 " + "0.880 0.960 1.040 1.120 1.200 1.280\n"; + const RadarHeader h = parseRadarHead(head); + EXPECT_EQ(h.samples, 516); + EXPECT_EQ(h.channels, 16); + EXPECT_EQ(h.lastTrace, 60448); + EXPECT_EQ(h.traces, 3778); // 60448/16 + EXPECT_EQ(h.bits, 16); + EXPECT_EQ(h.endianType, 1); + EXPECT_DOUBLE_EQ(h.distanceInterval, 0.099194); + ASSERT_EQ(h.chXOffsets.size(), 16u); + EXPECT_DOUBLE_EQ(h.chXOffsets.front(), 0.080); + EXPECT_DOUBLE_EQ(h.chXOffsets.back(), 1.280); +} + +TEST(NormalizedRadarHead, MissingRequiredFieldThrows) { + EXPECT_THROW(parseRadarHead("SAMPLES:516\nNUMBER_OF_CH:16\n"), std::runtime_error); +} + +TEST(NormalizedRadarHead, DepthSpacingUsesDefaultVelocityWhenNoDielectric) { + const std::string head = "SAMPLES:516\nNUMBER_OF_CH:16\nLAST_TRACE:32\n" + "TIMEWINDOW:96.419553\nDIELECTRIC:\n"; + const RadarHeader h = parseRadarHead(head); + EXPECT_NEAR(waveVelocityMperNs(h), 0.1, 1e-9); // 无介电 → 默认 0.1 + const double dz = depthSpacingZ(h); + EXPECT_NEAR(dz, (96.419553 / 515.0) * 0.1 / 2.0, 1e-9); +} +``` + +- [ ] **Step 2: 跑测试确认失败** + +Run: `cmake --build build/release --target geopro_tests 2>&1 | head` +Expected: 编译失败(头不存在)。 + +- [ ] **Step 3: 写实现** + +`src/io/gpr/NormalizedRadarReader.hpp`:放上面 Interfaces 的结构体与三个函数声明,加 `#include `/``,`#ifndef` 守卫。 +`src/io/gpr/NormalizedRadarReader.cpp`(解析 KEY:VALUE): +```cpp +#include "io/gpr/NormalizedRadarReader.hpp" +#include +#include +#include +#include +namespace geopro::io::gpr { +namespace { +std::map parseKv(const std::string& text) { + std::map kv; + std::istringstream in(text); + std::string line; + while (std::getline(in, line)) { + const auto pos = line.find(':'); + if (pos == std::string::npos) continue; + std::string k = line.substr(0, pos), v = line.substr(pos + 1); + auto trim = [](std::string& s) { + const auto a = s.find_first_not_of(" \t\r\n"); + const auto b = s.find_last_not_of(" \t\r\n"); + s = (a == std::string::npos) ? "" : s.substr(a, b - a + 1); + }; + trim(k); trim(v); + kv[k] = v; + } + return kv; +} +int reqInt(const std::map& kv, const char* k) { + auto it = kv.find(k); + if (it == kv.end() || it->second.empty()) + throw std::runtime_error(std::string("规范化 .head 缺字段: ") + k); + return std::stoi(it->second); +} +double optD(const std::map& kv, const char* k, double dv) { + auto it = kv.find(k); + return (it == kv.end() || it->second.empty()) ? dv : std::stod(it->second); +} +} // namespace + +RadarHeader parseRadarHead(const std::string& headText) { + const auto kv = parseKv(headText); + RadarHeader h; + h.samples = reqInt(kv, "SAMPLES"); + h.channels = reqInt(kv, "NUMBER_OF_CH"); + h.lastTrace = reqInt(kv, "LAST_TRACE"); + if (h.channels <= 0 || h.lastTrace % h.channels != 0) + throw std::runtime_error("LAST_TRACE 不能被 NUMBER_OF_CH 整除"); + h.traces = static_cast(h.lastTrace / h.channels); + h.bits = static_cast(optD(kv, "BITS", 16)); + h.endianType = static_cast(optD(kv, "ENDIAN_TYPE", 1)); + h.distanceInterval = optD(kv, "DISTANCE_INTERVAL", 1.0); + h.timeWindowNs = optD(kv, "TIMEWINDOW", 0.0); + h.dielectric = optD(kv, "DIELECTRIC", 0.0); + auto it = kv.find("CH_X_OFFSETS"); + if (it != kv.end() && !it->second.empty()) { + std::istringstream os(it->second); + double v; + while (os >> v) h.chXOffsets.push_back(v); + } + return h; +} +double waveVelocityMperNs(const RadarHeader& h) { + return h.dielectric > 0.0 ? 0.2998 / std::sqrt(h.dielectric) : 0.1; +} +double depthSpacingZ(const RadarHeader& h) { + if (h.samples <= 1 || h.timeWindowNs <= 0.0) return 0.0; + return (h.timeWindowNs / (h.samples - 1)) * waveVelocityMperNs(h) / 2.0; +} +} // namespace geopro::io::gpr +``` +加 `src/io/CMakeLists.txt`:`gpr/NormalizedRadarReader.cpp`。加 `tests/CMakeLists.txt`:`io/gpr/test_normalized_radar_reader.cpp`。 + +- [ ] **Step 4: 跑测试确认通过** + +Run: `cmake --build build/release --target geopro_tests && ctest --test-dir build/release -R NormalizedRadarHead -V` +Expected: 3 个用例 PASS。 + +- [ ] **Step 5: Commit** + +```bash +git add src/io/gpr/NormalizedRadarReader.hpp src/io/gpr/NormalizedRadarReader.cpp \ + src/io/CMakeLists.txt tests/io/gpr/test_normalized_radar_reader.cpp tests/CMakeLists.txt +git commit -m "feat(radar): 规范化 .head 解析(维度/字节序/通道偏移/深度间距)" +``` + +--- + +### Task 3: 规范化 `.data` 立方体读取(position-major,16-bit) + +**Files:** +- Modify: `src/io/gpr/NormalizedRadarReader.hpp`(加 `readRadarDataCube` 声明) +- Modify: `src/io/gpr/NormalizedRadarReader.cpp`(加实现) +- Test: `tests/io/gpr/test_normalized_radar_reader.cpp`(加用例) + +**Interfaces:** +- Produces: + ```cpp + // 读规范化 .data → 扁平 int16 立方体,position-major 索引 ((t*M + c)*N + s)。 + // 按 header.bits/endianType 解码;P0 仅 16-bit(32-bit 抛 not implemented)。 + // 文件大小须 == lastTrace*samples*(bits/8),否则抛 std::runtime_error。 + std::vector readRadarDataCube(const std::string& dataPath, + const RadarHeader& h); + ``` +- Consumes: `RadarHeader`(Task 2)。 + +- [ ] **Step 1: 写失败测试** + +加到 `test_normalized_radar_reader.cpp`: +```cpp +#include +#include +#include +namespace fs = std::filesystem; + +TEST(NormalizedRadarData, ReadsPositionMajorCubeLittleEndian) { + // K=2 道, M=3 通道, N=2 采样; 值 v(t,c,s)=int16(100*t+10*c+s)。position-major 写。 + fs::path dir = fs::temp_directory_path() / "radar_data_test"; + fs::create_directories(dir); + const fs::path dp = dir / "L.data"; + { + std::ofstream f(dp, std::ios::binary); + for (int t = 0; t < 2; ++t) + for (int c = 0; c < 3; ++c) + for (int s = 0; s < 2; ++s) { + std::int16_t v = static_cast(100 * t + 10 * c + s); + f.write(reinterpret_cast(&v), sizeof(v)); // 小端(x86) + } + } + geopro::io::gpr::RadarHeader h; + h.samples = 2; h.channels = 3; h.lastTrace = 6; h.traces = 2; h.bits = 16; h.endianType = 1; + const auto cube = geopro::io::gpr::readRadarDataCube(dp.string(), h); + ASSERT_EQ(cube.size(), 2u * 3u * 2u); + auto at = [&](int t, int c, int s) { return cube[(size_t(t) * 3 + c) * 2 + s]; }; + EXPECT_EQ(at(0, 0, 0), 0); + EXPECT_EQ(at(1, 2, 1), 121); // 100+20+1 + EXPECT_EQ(at(0, 1, 0), 10); +} + +TEST(NormalizedRadarData, WrongFileSizeThrows) { + fs::path dir = fs::temp_directory_path() / "radar_data_test"; + fs::create_directories(dir); + const fs::path dp = dir / "bad.data"; + { std::ofstream f(dp, std::ios::binary); std::int16_t v = 0; f.write((char*)&v, 2); } + geopro::io::gpr::RadarHeader h; + h.samples = 2; h.channels = 3; h.lastTrace = 6; h.traces = 2; h.bits = 16; + EXPECT_THROW(geopro::io::gpr::readRadarDataCube(dp.string(), h), std::runtime_error); +} +``` + +- [ ] **Step 2: 跑测试确认失败** + +Run: `cmake --build build/release --target geopro_tests 2>&1 | head` +Expected: `readRadarDataCube` 未声明,编译失败。 + +- [ ] **Step 3: 写实现** + +`NormalizedRadarReader.hpp` 加声明(+`#include `)。`.cpp` 加: +```cpp +#include +#include +// ... +std::vector readRadarDataCube(const std::string& dataPath, + const RadarHeader& h) { + if (h.bits != 16) + throw std::runtime_error("readRadarDataCube: 暂仅支持 16-bit(BITS=" + + std::to_string(h.bits) + " 待实现)"); + const std::size_t n = static_cast(h.lastTrace) * h.samples; + const std::uintmax_t expect = static_cast(n) * 2; + std::error_code ec; + const auto fsize = std::filesystem::file_size(dataPath, ec); + if (ec || fsize != expect) + throw std::runtime_error("规范化 .data 大小不符: " + dataPath); + std::vector cube(n); + std::ifstream f(dataPath, std::ios::binary); + if (!f) throw std::runtime_error("打开 .data 失败: " + dataPath); + f.read(reinterpret_cast(cube.data()), static_cast(expect)); + if (!f) throw std::runtime_error("读 .data 失败: " + dataPath); + if (h.endianType == 2) // 大端 → 主机小端(x86),逐元素交换字节 + for (auto& v : cube) { + const std::uint16_t u = static_cast(v); + v = static_cast((u >> 8) | (u << 8)); + } + return cube; +} +``` + +- [ ] **Step 4: 跑测试确认通过** + +Run: `cmake --build build/release --target geopro_tests && ctest --test-dir build/release -R NormalizedRadarData -V` +Expected: 2 个用例 PASS。 + +- [ ] **Step 5: Commit** + +```bash +git add src/io/gpr/NormalizedRadarReader.hpp src/io/gpr/NormalizedRadarReader.cpp \ + tests/io/gpr/test_normalized_radar_reader.cpp +git commit -m "feat(radar): 规范化 .data 立方体读取(position-major/16bit/字节序)" +``` + +--- + +### Task 4: 规范化 `.cor` 轨迹解析 + +本期只解析(单线渲染不依赖),为 P1 多线配准预留。 + +**Files:** +- Modify: `src/io/gpr/NormalizedRadarReader.hpp`(加 `TracePos` + `parseRadarCor`) +- Modify: `src/io/gpr/NormalizedRadarReader.cpp` +- Test: `tests/io/gpr/test_normalized_radar_reader.cpp`(加用例) + +**Interfaces:** +- Produces: + ```cpp + struct TracePos { int index = 0; double lat = 0, lon = 0, elev = 0; int solution = 0; }; + // 解析 .cor:跳过 "VERSION:" 行,每行 [序号 纬度 N/S 经度 E/W 高程 M 解状态] (制表/空格分隔)。 + std::vector parseRadarCor(const std::string& corText); + ``` + +- [ ] **Step 1: 写失败测试** + +```cpp +TEST(NormalizedRadarCor, ParsesRowsSkippingVersion) { + const std::string cor = + "VERSION:1\n" + "1\t317.179340\tN\t472.759046\tE\t49.980000\tM\t4\n" + "12\t317.201303\tN\t472.700649\tE\t51.040000\tM\t4\n"; + const auto pts = geopro::io::gpr::parseRadarCor(cor); + ASSERT_EQ(pts.size(), 2u); + EXPECT_EQ(pts[0].index, 1); + EXPECT_DOUBLE_EQ(pts[0].lat, 317.179340); + EXPECT_DOUBLE_EQ(pts[0].lon, 472.759046); + EXPECT_DOUBLE_EQ(pts[1].elev, 51.040000); + EXPECT_EQ(pts[1].solution, 4); +} +``` + +- [ ] **Step 2: 跑测试确认失败** + +Run: `cmake --build build/release --target geopro_tests 2>&1 | head` +Expected: `parseRadarCor` 未声明。 + +- [ ] **Step 3: 写实现** + +`.hpp` 加 `TracePos`/`parseRadarCor`。`.cpp` 加: +```cpp +std::vector parseRadarCor(const std::string& corText) { + std::vector out; + std::istringstream in(corText); + std::string line; + while (std::getline(in, line)) { + if (line.empty() || line.rfind("VERSION", 0) == 0) continue; + std::istringstream ls(line); + TracePos p; std::string ns, ew, m; + if (ls >> p.index >> p.lat >> ns >> p.lon >> ew >> p.elev >> m >> p.solution) + out.push_back(p); + } + return out; +} +``` + +- [ ] **Step 4: 跑测试确认通过** + +Run: `cmake --build build/release --target geopro_tests && ctest --test-dir build/release -R NormalizedRadarCor -V` +Expected: PASS。 + +- [ ] **Step 5: Commit** + +```bash +git add src/io/gpr/NormalizedRadarReader.hpp src/io/gpr/NormalizedRadarReader.cpp \ + tests/io/gpr/test_normalized_radar_reader.cpp +git commit -m "feat(radar): 规范化 .cor 轨迹解析(P1 配准预留)" +``` + +--- + +### Task 5: 规范化体 bridge `buildLineVolumeFromNormalized` + +组合 reader + `assembleRadarVolume`,产出 `BuiltI16`。 + +**Files:** +- Create: `src/io/gpr/NormalizedRadarVolumeBridge.hpp` +- Create: `src/io/gpr/NormalizedRadarVolumeBridge.cpp` +- Modify: `src/io/CMakeLists.txt` +- Test: `tests/io/gpr/test_normalized_radar_bridge.cpp` +- Modify: `tests/CMakeLists.txt` + +**Interfaces:** +- Produces: + ```cpp + namespace geopro::io::gpr { + // 读 {lineDir}/{prefix}.head + .data → assembleRadarVolume → BuiltI16(轴 X=道/Y=通道/Z=采样)。 + // coarse 沿道下采样;targetDy 线内通道插值(读 .head CH_X_OFFSETS)。失败抛 std::runtime_error。 + geopro::core::BuiltI16 buildLineVolumeFromNormalized(const std::string& lineDir, + const std::string& linePrefix, + int coarse = 4, + double targetDy = 0.025); + } + ``` +- Consumes: `parseRadarHead`/`readRadarDataCube`/`depthSpacingZ`(Task 2-3)、`assembleRadarVolume`(Task 1)。 + +- [ ] **Step 1: 写失败测试** + +`tests/io/gpr/test_normalized_radar_bridge.cpp`: +```cpp +#include +#include +#include +#include +#include "core/algo/GprVolumeBuilder.hpp" +#include "io/gpr/NormalizedRadarVolumeBridge.hpp" +namespace fs = std::filesystem; + +TEST(NormalizedRadarBridge, BuildsVolumeWithExpectedAxes) { + // K=4 道, M=2 通道, N=3 采样, 无通道偏移(不插值), coarse=1。 + fs::path dir = fs::temp_directory_path() / "radar_bridge_test"; + fs::create_directories(dir); + { std::ofstream f(dir / "L.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(dir / "L.data", std::ios::binary); + for (int t = 0; t < 4; ++t) for (int c = 0; c < 2; ++c) for (int s = 0; s < 3; ++s) { + std::int16_t v = static_cast(t * 10 + c * 100 + s); + f.write(reinterpret_cast(&v), 2); } } + const auto b = geopro::io::gpr::buildLineVolumeFromNormalized( + (dir).string(), "L", /*coarse=*/1, /*targetDy=*/0.0); // targetDy=0 不插值 + EXPECT_EQ(b.vol.nx(), 4); // 道 + EXPECT_EQ(b.vol.ny(), 2); // 通道 + EXPECT_EQ(b.vol.nz(), 3); // 采样 + EXPECT_DOUBLE_EQ(b.spacing[0], 0.1); // dx=DISTANCE_INTERVAL + 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 +} +``` + +- [ ] **Step 2: 跑测试确认失败** + +Run: `cmake --build build/release --target geopro_tests 2>&1 | head` +Expected: 头不存在,编译失败。 + +- [ ] **Step 3: 写实现** + +`src/io/gpr/NormalizedRadarVolumeBridge.hpp`:守卫 + 声明。`.cpp`: +```cpp +#include "io/gpr/NormalizedRadarVolumeBridge.hpp" +#include "io/gpr/NormalizedRadarReader.hpp" +#include "io/gpr/RadarVolumeAssembler.hpp" +namespace geopro::io::gpr { +geopro::core::BuiltI16 buildLineVolumeFromNormalized(const std::string& lineDir, + const std::string& linePrefix, + int coarse, double targetDy) { + const std::string head = lineDir + "/" + linePrefix + ".head"; + const std::string data = lineDir + "/" + linePrefix + ".data"; + std::string headText; + { std::ifstream f(head); if (!f) throw std::runtime_error("打开 .head 失败: " + head); + std::stringstream ss; ss << f.rdbuf(); headText = ss.str(); } + const RadarHeader h = parseRadarHead(headText); + const std::vector cube = readRadarDataCube(data, h); + const int M = h.channels, N = h.samples; + RadarCubeDesc d; + d.channels = M; d.traces = h.traces; d.samples = N; + d.chXOffsets = h.chXOffsets; + d.dxBase = h.distanceInterval > 1e-9 ? h.distanceInterval : 1.0; + d.dyWhenNotInterpolated = + (h.chXOffsets.size() >= 2) + ? (h.chXOffsets.back() - h.chXOffsets.front()) / (M - 1) + : 1.0; + d.dz = depthSpacingZ(h) > 1e-12 ? depthSpacingZ(h) : 1.0; + CubeSampler sample = [&cube, M, N](int c, int t, int s) { + return static_cast(cube[(static_cast(t) * M + c) * N + s]); + }; + return assembleRadarVolume(d, sample, coarse, targetDy); +} +} // namespace geopro::io::gpr +``` +(`.cpp` 顶部 `#include `/``/``。)加 `src/io/CMakeLists.txt`:`gpr/NormalizedRadarVolumeBridge.cpp`;加 `tests/CMakeLists.txt`:`io/gpr/test_normalized_radar_bridge.cpp`。 + +- [ ] **Step 4: 跑测试确认通过** + +Run: `cmake --build build/release --target geopro_tests && ctest --test-dir build/release -R NormalizedRadarBridge -V` +Expected: PASS。 + +- [ ] **Step 5: Commit** + +```bash +git add src/io/gpr/NormalizedRadarVolumeBridge.hpp src/io/gpr/NormalizedRadarVolumeBridge.cpp \ + src/io/CMakeLists.txt tests/io/gpr/test_normalized_radar_bridge.cpp tests/CMakeLists.txt +git commit -m "feat(radar): 规范化体 bridge buildLineVolumeFromNormalized" +``` + +--- + +### Task 6: 数据层 `createRadarVolumeGrid` + +**Files:** +- Modify: `src/data/GprVolumeRepository.hpp`(加声明) +- Modify: `src/data/GprVolumeRepository.cpp`(加实现) +- Test: `tests/data/test_gpr_volume_repository.cpp`(加用例) + +**Interfaces:** +- Produces: + ```cpp + // 规范化测线 → BuiltI16(buildLineVolumeFromNormalized) → 反量化 VolumeGrid。 + VolumeGrid createRadarVolumeGrid(const std::string& lineDir, const std::string& linePrefix, + int coarse = 4, double targetDy = 0.025); + ``` +- Consumes: `buildLineVolumeFromNormalized`(Task 5)、`builtI16ToVolumeGrid`(现成)。 + +- [ ] **Step 1: 写失败测试** + +加到 `tests/data/test_gpr_volume_repository.cpp`(仿现有 `createGprVolumeGrid` 测试,用 Task 5 同款合成 .head/.data): +```cpp +TEST(GprVolumeRepository, CreateRadarVolumeGridFromNormalized) { + fs::path dir = fs::temp_directory_path() / "radar_repo_test"; + fs::create_directories(dir); + { std::ofstream f(dir / "L.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(dir / "L.data", std::ios::binary); + for (int t = 0; t < 4; ++t) for (int c = 0; c < 2; ++c) for (int s = 0; s < 3; ++s) { + std::int16_t v = static_cast(t * 10 + c * 100 + s); + f.write(reinterpret_cast(&v), 2); } } + const auto grid = geopro::data::createRadarVolumeGrid(dir.string(), "L", 1, 0.0); + EXPECT_EQ(grid.vol.nx(), 4); + EXPECT_EQ(grid.vol.ny(), 2); + EXPECT_EQ(grid.vol.nz(), 3); + EXPECT_GT(grid.vmax, grid.vmin); +} +``` + +- [ ] **Step 2: 跑测试确认失败** + +Run: `cmake --build build/release --target geopro_tests 2>&1 | head` +Expected: `createRadarVolumeGrid` 未声明。 + +- [ ] **Step 3: 写实现** + +`GprVolumeRepository.hpp` 在 `createGprVolumeGrid` 声明后加 `createRadarVolumeGrid`(注释说明走规范化链)。`.cpp` 加: +```cpp +#include "io/gpr/NormalizedRadarVolumeBridge.hpp" +// ... +VolumeGrid createRadarVolumeGrid(const std::string& lineDir, + const std::string& linePrefix, int coarse, + double targetDy) { + const geopro::core::BuiltI16 built = + geopro::io::gpr::buildLineVolumeFromNormalized(lineDir, linePrefix, coarse, targetDy); + return builtI16ToVolumeGrid(built); +} +``` + +- [ ] **Step 4: 跑测试确认通过** + +Run: `cmake --build build/release --target geopro_tests && ctest --test-dir build/release -R "GprVolumeRepository" -V` +Expected: 新用例 + 现有用例全 PASS。 + +- [ ] **Step 5: Commit** + +```bash +git add src/data/GprVolumeRepository.hpp src/data/GprVolumeRepository.cpp \ + tests/data/test_gpr_volume_repository.cpp +git commit -m "feat(radar): 数据层 createRadarVolumeGrid(规范化→VolumeGrid)" +``` + +--- + +### Task 7: DS 优先登记 `registerRadarDataset` + `loadVolume` 懒加载后台建体 + +把规范化测线登记成一条 **`dd_radar_3d`** DS(不是游离 `dd_voxel` vol-N),**只存元数据、不建体**;首次勾选时 `loadVolume` 在**后台线程**建体(仿 `finalizeVolume`,避免 UI 冻结 + spinner 卡死)。运行期路由只认 `volumes_` 成员(已验:`isVolumeDataset`=`volumes_.count`、`loadVolume` 按 dsId 查、`addDatasetAsync` 按 `isVolumeDataset` 分流),故 `isVolumeDataset/addDatasetAsync` 零改动。 + +**Files:** +- Modify: `src/data/api/Api3dRepository.hpp`(`StoredVolume` 加字段 + 声明 `registerRadarDataset`) +- Modify: `src/data/api/Api3dRepository.cpp`(`registerRadarDataset` + `loadVolume` radar 分支 + `volumeRows()` 输出 `sv.ddCode`) +- Test: `tests/data/test_3d_repo.cpp`(加用例:登记为 `dd_radar_3d` + `loadVolume` 同步退化路径产有效体) + +**Interfaces:** +- Produces: + ```cpp + // 登记一条 dd_radar_3d 体 DS:只存元数据(lineDir/prefix/coarse),不建体。id="radar-"+(++counter)。 + std::string registerRadarDataset(const std::string& lineDir, const std::string& linePrefix, + const std::string& name, const std::string& structParentId, + int coarse = 4); + ``` +- Consumes: `createRadarVolumeGrid`(Task 6)。 +- `StoredVolume`(`Api3dRepository.hpp:139`) 新增字段:`std::string lineDir, linePrefix, ddCode = "dd_voxel", structParentId; int coarse = 4;`。 + +- [ ] **Step 1: 写失败测试** + +加到 `tests/data/test_3d_repo.cpp`(用 Task 6 同款合成目录;构造 repo 照本文件现有用例的构造方式。gtest 无 `QCoreApplication` → `loadVolume` 走同步退化路径,回调即时触发): +```cpp +TEST(Api3dRepository, RegisterRadarDatasetRoutesAsDdRadar3d) { + // ...写合成 dir/L.head + L.data(同 Task 6)... + geopro::data::Api3dRepository repo(/*照本文件现有构造*/); + const std::string id = repo.registerRadarDataset(dir.string(), "L", "测线L", + /*structParentId=*/"tm-1", /*coarse=*/1); + EXPECT_FALSE(id.empty()); + EXPECT_TRUE(repo.isVolumeDataset(id)); // 运行期按 volumes_ 成员判体 → 真(即便未建体) + const auto rows = repo.volumeRows(); + ASSERT_FALSE(rows.empty()); + EXPECT_EQ(rows.back().ddCode, "dd_radar_3d"); // 不是 dd_voxel + EXPECT_EQ(rows.back().structParentId, "tm-1"); +} + +TEST(Api3dRepository, LoadVolumeBuildsRadarLazily) { + // ...写合成 dir/L.head + L.data(同上)... + geopro::data::Api3dRepository repo(/*照本文件现有构造*/); + const std::string id = repo.registerRadarDataset(dir.string(), "L", "测线L", "", 1); + bool got = false; + repo.loadVolume(id, + [&](geopro::data::VolumeGrid g, geopro::core::ColorScale) { + got = true; + EXPECT_GT(g.vol.nx(), 0); EXPECT_GT(g.vol.ny(), 0); EXPECT_GT(g.vol.nz(), 0); + }, + [&](const std::string& e) { FAIL() << e; }); + EXPECT_TRUE(got); // 无 QCoreApplication → 同步交付 +} +``` + +- [ ] **Step 2: 跑测试确认失败** + +Run: `cmake --build build/release --target geopro_tests 2>&1 | head` +Expected: `registerRadarDataset` 未声明。 + +- [ ] **Step 3: 写实现** + +`Api3dRepository.hpp`:`StoredVolume`(:139 附近) 加 `std::string lineDir, linePrefix, ddCode = "dd_voxel", structParentId; int coarse = 4;`;在 `createGprVolume` 声明后加 `registerRadarDataset` 声明。 +`.cpp` 加 `registerRadarDataset`(只存元数据,**不建体**): +```cpp +std::string Api3dRepository::registerRadarDataset(const std::string& lineDir, + const std::string& linePrefix, + const std::string& name, + const std::string& structParentId, + int coarse) { + const std::string id = "radar-" + std::to_string(++volumeCounter_); + StoredVolume sv; + sv.name = name; + sv.ddCode = "dd_radar_3d"; + sv.lineDir = lineDir; + sv.linePrefix = linePrefix; + sv.coarse = coarse; + sv.structParentId = structParentId; + sv.createTime = QDateTime::currentDateTime() + .toString(QStringLiteral("yyyy-MM-dd HH:mm")).toStdString(); + volumes_[id] = std::move(sv); // 不预填 cachedGrid → 懒建 + return id; +} +``` +`loadVolume`(`Api3dRepository.cpp:347` 的 `if (sv.cachedGrid)` 块之后、IDW 路径 `:351` 之前)插入 radar 分支(仿 `finalizeVolume:268-335` 后台线程范式,`#include "data/GprVolumeRepository.hpp"` 已在): +```cpp + if (!sv.linePrefix.empty()) { // radar DS:后台建体,避免阻塞 UI(与 finalizeVolume 同范式) + const std::string lineDir = sv.lineDir, linePrefix = sv.linePrefix; + const int coarse = sv.coarse; + auto deliver = [this, dsId, onOk, onErr](std::shared_ptr g, std::string err) { + if (!g) { onErr("Api3dRepository::loadVolume(radar): " + err); return; } + core::ColorScale scale; + const double mid = 0.5 * (g->vmin + g->vmax); + scale.addStop(g->vmin, core::Rgba{20, 24, 40, 255}); + scale.addStop(mid, core::Rgba{140, 140, 150, 255}); + scale.addStop(g->vmax, core::Rgba{235, 232, 220, 255}); + if (auto it2 = volumes_.find(dsId); it2 != volumes_.end()) { + it2->second.cachedGrid = *g; // 缓存 → 下次命中直渲 + it2->second.cachedScale = scale; + } + onOk(*g, scale); + }; + auto compute = [lineDir, linePrefix, coarse]() { + std::shared_ptr g; std::string err; + try { g = std::make_shared( + geopro::data::createRadarVolumeGrid(lineDir, linePrefix, coarse)); } + catch (const std::exception& e) { err = e.what(); } + return std::make_tuple(g, err); + }; + if (!QCoreApplication::instance()) { // headless/单测 → 同步 + auto r = compute(); deliver(std::get<0>(r), std::get<1>(r)); return; + } + std::thread([compute, deliver]() mutable { + auto r = compute(); + auto g = std::get<0>(r); auto err = std::get<1>(r); + QMetaObject::invokeMethod(qApp, + [deliver, g, err]() mutable { deliver(std::move(g), std::move(err)); }, + Qt::QueuedConnection); + }).detach(); + return; + } +``` +改 `volumeRows()`(`Api3dRepository.cpp:170` 附近):`r.ddCode = "dd_voxel";` → `r.ddCode = sv.ddCode;`,且 `r.structParentId = sv.request ? sv.request->structParentId : sv.structParentId;`(radar 无 request,用 sv.structParentId)。 + +- [ ] **Step 4: 跑测试确认通过** + +Run: `cmake --build build/release --target geopro_tests && ctest --test-dir build/release -R "Api3dRepository" -V` +Expected: 2 个新用例 + 现有用例全 PASS(现有体 `sv.ddCode` 默认 `"dd_voxel"`、`linePrefix` 空不走 radar 分支,行为不变)。 + +- [ ] **Step 5: Commit** + +```bash +git add src/data/api/Api3dRepository.hpp src/data/api/Api3dRepository.cpp tests/data/test_3d_repo.cpp +git commit -m "feat(radar): registerRadarDataset 登记 dd_radar_3d DS + loadVolume 懒加载后台建体" +``` + +--- + +### Task 8: 本地导入入口 + `dd_radar_3d` 三处 ddCode gate 放开 + +导入登记 DS(瞬时,建体走 Task 7 的懒加载后台路径),并放开三处硬编码 `dd_voxel` 的 ddCode 判断让 radar 体可切片/调色阶/看详情。**评审 CRITICAL:列表右键「生成切片」是 UI 唯一切片入口,不放开 `CategorySection.cpp:397` 则 radar 体无法切片、P0 验收②不可达。** + +**Files:** +- Modify: `src/app/main.cpp`(导入菜单动作 + `detailRequested` gate) +- Modify: `src/app/panels/columns/CategorySection.cpp:397`(右键「生成切片」+「色阶」同一 if 块的 gate) + +**Interfaces:** +- Consumes: `scene3dRepo->registerRadarDataset`(Task 7)、`refreshAnalysis`、`analysisTab`、`window`。 + +- [ ] **Step 1: 加导入菜单动作** + +在 `main.cpp` 中 `refreshAnalysis` 已定义、`analysisTab` 已存在处(紧接 `generateVolumeRequested` 连接块之后,约 :982 后)插入。**登记瞬时、不建体**——勾选后由 Task 7 的 `loadVolume` 后台建体,列表项 spinner 显进度,失败由 controller `loadFailed`→toast 兜底(`main.cpp:995`): +```cpp +// 本地导入三维雷达测线目录(后端未就绪的过渡入口):选目录+前缀 → registerRadarDataset(登记dd_radar_3d DS) → 勾选→后台建体渲染。 +{ + QMenu* radarMenu = window.menuBar()->addMenu(QStringLiteral("三维雷达")); + QAction* importAct = radarMenu->addAction(QStringLiteral("导入测线目录(本地)…")); + QObject::connect(importAct, &QAction::triggered, &window, + [&window, scene3dRepo, refreshAnalysis, analysisTab]() { + 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 归属)。 + const std::string newId = scene3dRepo->registerRadarDataset( + dir.toLocal8Bit().toStdString(), prefix.toLocal8Bit().toStdString(), + prefix.toStdString(), /*structParentId=*/std::string(), /*coarse=*/4); + { const QSignalBlocker block(analysisTab); refreshAnalysis(); } // DS 进三维体段(不触发渲染) + const QString qid = QString::fromStdString(newId); + analysisTab->setItemChecked(qid, true); // 勾选 → addDatasetAsync → loadVolume 后台建体渲染 + analysisTab->setItemBusy(qid, true); // spinner; 渲染完成由 datasetRendered 撤(main.cpp:987) + analysisTab->scrollItemToTop(qid); + }); +} +``` +确认 `main.cpp` 顶部已 include ``/``/``/``(缺则补)。 + +- [ ] **Step 2: 放开列表右键「生成切片/色阶」gate(评审 CRITICAL)** + +`src/app/panels/columns/CategorySection.cpp`:右键菜单的「生成切片」(:398-402) 与「色阶」(:403) **同在 `:397` 的 `if (ddCode == "dd_voxel")` 块内** → **只改 :397 这一处**判定即同时放开二者(无独立 :403 gate): +- `:397` `if (ddCode == QStringLiteral("dd_voxel"))` → `if (ddCode == QStringLiteral("dd_voxel") || ddCode == QStringLiteral("dd_radar_3d"))` + +(这是 UI 唯一切片创建入口 `sliceRequested`→`main.cpp:1061`→`interactionMgr->addSlice`;不改则 radar 体右键无切片/色阶。`addSlice` 依赖的 volume image 由勾选→后台建体→`addVolume` 附着,与"先勾选渲染再切片"次序一致。) + +- [ ] **Step 3: dd_radar_3d 双击详情路由** + +`main.cpp` 的 `analysisTab` `detailRequested` 处理器(约 :1003-1013)目前只认 `dd_slice`/`dd_voxel`。把 `dd_voxel` 分支 `if (ddCode == QStringLiteral("dd_voxel"))` 改为 `if (ddCode == QStringLiteral("dd_voxel") || ddCode == QStringLiteral("dd_radar_3d"))`(`volumeInfo(dsId)` 对 radar DS 有效——它在 `volumes_` 里、loadVolume 后 `cachedGrid` 就绪 loaded=true)。 +> 注(MEDIUM 显示瑕疵,本期可不处理):体属性对话框对 radar 会显示 `StoredVolume.params` 的默认 IDW 参数(cellXY/power…)与 pointCount=0,因 radar 不走 IDW。如需净化,给 `VolumePropertiesDialog` 加"radar 体隐藏插值参数段"分支,登记到 backlog。 + +- [ ] **Step 4: 构建 app** + +Run: `build.bat app`(先关掉在跑的 app,避免 LNK1104) +Expected: 链接通过,生成 exe。 + +- [ ] **Step 5: 人工联调验收** + +启动 app → 登录 → 菜单「三维雷达 → 导入测线目录(本地)」→ 选 `samples/radar/malamira_南同大道`、前缀 `南同大道_000` → 三维体段出现 `南同大道_000`(**ddCode=dd_radar_3d**)行、自动勾选、列表项转 spinner、后台建体完成后渲染出体(spinner 撤)。验:① 体可见;② **右键该行有「生成切片」**→切 updown/leftright/frontback 三向;③ 画点/线/面异常并保存、挂在体下;④ 双击该行弹体属性。日志看 `geopro_*.log` 无异常。 + +- [ ] **Step 6: Commit** + +```bash +git add src/app/main.cpp src/app/panels/columns/CategorySection.cpp +git commit -m "feat(radar): 导入入口 + dd_radar_3d 切片/色阶/详情 gate 放开" +``` + +--- + +### Task 9: 双数据集互证(明星路 in-app 首次稠密渲染) + +按评审 HIGH-2:明星路 in-app 渲染从未跑过,需接其导入入口(复用现成 `createGprVolume`,仍走 `.iprb` 路径、体 ddCode 仍为 `dd_voxel` vol-N——Impulse 归 `dd_radar_3d` 待其规范化插件就绪),与 Mala 同入口形态,证下游对 14ch/16ch 几何无关。**只验"管路通+几何无关",不验成像一致**(明星路处理后 vs Mala 原始)。 + +**Files:** +- Modify: `src/app/main.cpp`(Task 8 的 `radarMenu` 加第二个动作) + +- [ ] **Step 1: 加 Impulse 导入动作** + +在 Task 8 的 `radarMenu` 内追加: +```cpp +QAction* importImpulseAct = radarMenu->addAction(QStringLiteral("导入Impulse测线目录(.iprb)…")); +QObject::connect(importImpulseAct, &QAction::triggered, &window, + [&window, scene3dRepo, refreshAnalysis, analysisTab, vtkLoading]() { + 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体…")); + QTimer::singleShot(0, &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 永久转圈 —— 评审 MEDIUM)。 + analysisTab->setItemChecked(qid, true); + analysisTab->scrollItemToTop(qid); + }); +}); +``` +> 注:明星路体经 `createGprVolume` 仍是 eager 同步建体(~74MB×14ch,建体期 UI 短冻,已用 `vtkLoading` 蒙版遮挡)。这是现有 `.iprb` 路径、本期不改;Mala 走 Task 7 懒加载后台路径不受影响。 + +- [ ] **Step 2: 构建 + 双数据集联调** + +Run: `build.bat app`(先关 app) +验收:分别导入 `D:/Downloads/明星路`(前缀 `明星路_001`) 与 `samples/radar/malamira_南同大道`(前缀 `南同大道_000`),两体都能渲染 + 切片 + 异常。以 `gpr_poc` CLI 的明星路成像为离线对照基线,确认 in-app 明星路体几何合理(不要求与 Mala 成像可比)。 + +- [ ] **Step 3: Commit** + +```bash +git add src/app/main.cpp +git commit -m "feat(radar): Impulse 测线本地导入入口(双数据集互证下游)" +``` + +--- + +## 完成判据 +- `geopro_tests` 全绿(含新 reader/assembler/bridge/repo 用例 + 现有 Gpr3dv/Api3d 无回归)。 +- app 内导入 Mala 规范化测线 → 渲染 + 三向切片 + 三类异常 OK。 +- app 内导入明星路 Impulse 测线 → 渲染 OK(双数据集互证)。 +- 未触碰 controller/view 渲染内核、切片/异常工具、三级树(下游零改动达成)。 diff --git a/docs/superpowers/specs/2026-06-29-3d-radar-volume-ingest-design.md b/docs/superpowers/specs/2026-06-29-3d-radar-volume-ingest-design.md index d18ebf9..144ca0d 100644 --- a/docs/superpowers/specs/2026-06-29-3d-radar-volume-ingest-design.md +++ b/docs/superpowers/specs/2026-06-29-3d-radar-volume-ingest-design.md @@ -39,29 +39,44 @@ 4. **数据类型**:int16 小端(`.rd3`/`bits=16`) / int32 小端(`.rd7`/`bits=32`)。`-32768` 是直达波饱和真实值,**非空值哨兵**。 5. **`BITS` 公式 bug**:客户文档 §3.3 `BITS = 文件大小/LAST_TRACE/NUMBER_OF_CH×8` **漏了 SAMPLES 维、量纲错**。 正确:`bytes = 文件大小/(LAST_TRACE×SAMPLES)`,并与扩展名交叉校验。**须同步后端**(见 `tools/radar_convert/README.md`)。 +6. **ddCode(数据字典 DD0623 权威)**:三维雷达体 DS = **`dd_radar_3d`**(本次新增,形态=三维插值模型); + 轨迹 DS = **`dd_trajectory_data`**(保留,复用现成 `TrajectoryStrategy`/`TrajectoryMapView`,零改动); + `dd_voxel` **仅物探反演体(电阻率/速度)、不含雷达**;`dd_slice` 切片共用;`dd_radar_rtk_trajectory` 已删除。 + **雷达体不复用 `dd_voxel`。** --- -## 3. 架构论点:只换最内层 reader,下游 100% 复用 +## 3. 架构论点:DS 优先 + 只换最内层 reader + +### 3a. 数据层:只换最内层 reader(下游建体链复用) 现有真实雷达体链路是分层的,**只有最内层 reader 绑定厂商格式**: ``` -Api3dRepository::createGprVolume [api/Api3dRepository.cpp:128] 注册 dd_voxel/灰度色阶/预填 cachedGrid - └ data::createGprVolumeGrid [data/GprVolumeRepository.cpp:38] - ├ io::gpr::buildLineVolumeFromGpr3dv ← 仅此层绑定厂商 .iprb/.ord(读 Impulse 原始) - └ data::builtI16ToVolumeGrid [data/GprVolumeRepository.cpp:11] int16→float 稠密体 - → 勾选 → VtkSceneController.loadVolume → VtkSceneView.addVolume → render::buildVoxel(GPU RayCast) - → SliceTool / InteractionManager / AnomalyDrawTool (切片 B/C-scan + 点线面异常,全现成) +data::createRadarVolumeGrid [data/GprVolumeRepository 新] + ├ io::gpr::buildLineVolumeFromNormalized ← 新写:仅此层认规范化 .head/.data + └ data::builtI16ToVolumeGrid [data/GprVolumeRepository.cpp:11] int16→float 稠密体 ``` +(范本:现有 `createGprVolume`[`Api3dRepository.cpp:128`]→`createGprVolumeGrid`→`buildLineVolumeFromGpr3dv`(Impulse .iprb)。 +我们换最内层 reader,复用 `builtI16ToVolumeGrid` 及其下游。)**产 `core::BuiltI16`(轴 X=道/Y=通道/Z=采样)即可**, +这正是 `GprVolumeRepository.cpp:14` 注释写明的"数据层方案 A"。 -**新增一个产 `core::BuiltI16`(同轴映射 X=道/Y=通道/Z=采样)的规范化 reader**,则 `builtI16ToVolumeGrid` -往下的渲染/切片/异常链**零改动**。这正是 `GprVolumeRepository.cpp:14` 注释写明的"数据层方案 A"。 +### 3b. DS 优先:运行期路由按 `volumes_` 成员,不看 ddCode(关键发现,已验真代码) +桌面端体渲染的运行期分流**只认 `Api3dRepository::volumes_` map 是否含此 dsId**,与 ddCode/"vol-" 前缀无关: +- `isVolumeDataset(dsId)` = `volumes_.count(dsId)`(`Api3dRepository.cpp:105`); +- `VtkSceneController::addDatasetAsync`(:182) 按 `isVolumeDataset` 分流体素/帘面; +- `loadVolume(dsId)`(:341) 唯一查找键 = `volumes_` 的 dsId;命中 `cachedGrid` 直渲(:347)。 + +**所以 DS 优先落地 = 把一条"真实 radar DS id"为 key 的 `StoredVolume` 写进 `volumes_`**(携 `ddCode=dd_radar_3d` + `lineDir/prefix`), +则 `isVolumeDataset/addDatasetAsync/loadVolume` **零改动**自动按体渲染。三维分析栏 voxel 段内容来自 `volumeRows()`→ +`section("voxel")->setDatasets`(`main.cpp:602`,**绕过 `splitByCategory`**),故 `dimensionOf`/`CategoryConfig` **也无需改**—— +唯一要改 `volumeRows()`(:170) 把硬编码 `dd_voxel` 改成输出该 DS 的真实 ddCode。 ### 完全复用、不碰的部分 -`render::buildVoxel`(GPU 体绘制)、三级树 voxel 段(`CategoryConfig.hpp:23` `dd_voxel`)、勾选→`loadVolume`→`addVolume`、 +`render::buildVoxel`(GPU 体绘制)、勾选→`addDatasetAsync`→`loadVolume`→`addVolume` 路由、`isVolumeDataset`、 切片(`SliceTool` updown 深度 C-scan / leftright·frontback B-scan + `InteractionManager`)、 -异常(`AnomalyDrawTool` 点/线/面 → `dd_anomaly` 挂体)、跨视图色阶联动、`builtI16ToVolumeGrid` 适配器。 +异常(`AnomalyDrawTool` 点/线/面 → `dd_anomaly` 挂体)、跨视图色阶联动、`builtI16ToVolumeGrid` 适配器、 +轨迹 `dd_trajectory_data` 详情视图。 --- @@ -94,8 +109,8 @@ convert(lineDir, prefix, outDir) -> {head, data, cor} # 厂商原始 → 规 | ① | `src/io/gpr/NormalizedRadarReader.{hpp,cpp}` **新** | 纯函数:`parseHead(.head)→RadarHeader`;`readDataCube(.data,header)→cube`(按 `BITS`+`ENDIAN_TYPE` 解二进制,`reshape(K,M,N)` 主序已定);`parseCor(.cor)→vector`(先解析,世界配准后置) | | ② | `src/io/gpr/NormalizedRadarVolumeBridge.{hpp,cpp}` **新** | `buildLineVolumeFromNormalized(lineDir,prefix,metrics,coarse,targetDy)→core::BuiltI16`。⚠️ **DRY**:`Gpr3dvVolumeBridge.cpp:133-197` 的值域扫描/`Quant`构造/逐体素填值/spacing 当前内联且耦合 Impulse 模型——动工时**先把这段抽成共享 helper**(签名接受抽象立方体访问器 `(c,t,s)→short` + 通道偏移/几何),两个 bridge 同调;`planChannelInterpolation`/`depthOfSample` 已是共享纯函数。**不要复制填体循环**(否则两份独立漂移) | | ③ | `src/data/GprVolumeRepository.{hpp,cpp}` 改 | 加 `createRadarVolumeGrid(lineDir,prefix,coarse,targetDy)`(调②,复用 `builtI16ToVolumeGrid`)。**不动**现有 `createGprVolumeGrid` | -| ④ | `src/data/api/Api3dRepository.{cpp,hpp}` 改 | 加 `createRadarVolume(...)`(≈`createGprVolume` 复制换调③),仍注册 `dd_voxel` | -| ⑤ | `src/app/main.cpp` 改 | 本地导入入口「导入三维雷达测线目录」:选目录+前缀 → `createRadarVolume` → `refreshAnalysis` → 自动勾选渲染(后端就绪后换按 DS 下载) | +| ④ | `src/data/api/Api3dRepository.{cpp,hpp}` 改 | **DS 优先 + 懒加载后台建体**(3 处):(a) `StoredVolume`(:139) 加 `lineDir/linePrefix/coarse/ddCode/structParentId` 字段(`ddCode` 默认 `"dd_voxel"`,存量行为不变);(b) 新 `registerRadarDataset(lineDir,prefix,name,structParentId,coarse)`——**只存元数据、不建体**,以 `ddCode="dd_radar_3d"` 写进 `volumes_`,id=`"radar-"+(++counter)`,瞬时返回;(c) `loadVolume`(:347 后) 加 radar 分支:未命中 cachedGrid 且有 `linePrefix` → **后台线程** `createRadarVolumeGrid`(仿 `finalizeVolume:268-335` 的 `std::thread`+`QMetaObject::invokeMethod(qApp,...)` 回主线程;无 `QCoreApplication` 时退化同步,可单测)→ 建体 + 灰度色阶 + 缓存 + async `onOk`;(d) `volumeRows()`(:170) 输出 `sv.ddCode` + `sv.structParentId`。运行期 `isVolumeDataset/addDatasetAsync` 按 `volumes_` 成员 → **零改动**。**懒加载+后台建体同时消除 UI 冻结(评审 HIGH)与 spinner 永久转圈(评审 MEDIUM)**。⚠️ `dimensionOf`/`CategoryConfig`/`splitByCategory` **不需改**(voxel 段走 `volumeRows` 注入、绕过分类,见 §3b) | +| ⑤ | `src/app/main.cpp` + `src/app/panels/columns/CategorySection.cpp` 改 | (1) 导入入口「三维雷达→导入测线目录」:选目录+前缀 → `registerRadarDataset` → `refreshAnalysis` → `setItemChecked(true)`+`setItemBusy(true)`+`scrollItemToTop`(同 `generateVolume:978-980`;渲染异步、spinner 由 `datasetRendered` 撤)。(2) **放开两处 ddCode gate**给 `dd_radar_3d`:`main.cpp:1011` `detailRequested`→体属性;**`CategorySection.cpp:397`** 列表右键 `if (ddCode=="dd_voxel")` 块(「生成切片」+「色阶」**同在此一个块内**,**只改 :397 这一处**即同时放开二者,无独立 :403 gate)——**不改则 radar 体无切片入口、P0 验收②不可达 — 评审 CRITICAL**| | ⑥ | 测试 | `tests/io/gpr/test_normalized_radar_reader.cpp`(喂 `tests/data/radar/` 截断样例,断言维度/字节序/通道插值/主序) | ### 几何映射(关键算法,纯函数易测) @@ -111,8 +126,8 @@ convert(lineDir, prefix, outDir) -> {head, data, cor} # 厂商原始 → 规 ## 6. 分期 - **P0(本期)**:①–⑥,单线规范化体打通。**验收**:导入 `samples/radar/malamira_南同大道`(如 000 线)→ - 树出现 `dd_voxel` → 勾选渲染 → 切 updown/leftright/frontback → 画点/线/面异常并挂体下。 -- **P1**:`.cor` 逐道 GPS 世界配准 + 高程、`.index` 打标、多线合一场景。 + 三维体段出现一条 **`dd_radar_3d`** DS → 勾选 → `loadVolume` 懒建体渲染 → 切 updown/leftright/frontback → 画点/线/面异常并挂体下。 +- **P1**:`.cor` 逐道 GPS 世界配准 + 高程、`.index` 打标、多线合一场景、轨迹 DS(`dd_trajectory_data`) 一并登记、DS 进左侧数据集树(挂 TM)。 - **P2**:大体量 → 把 `gpr_poc` 的 `ChunkedVolumeStore`+`*VolumeSource` 核外/LOD 接进 app(碰 controller/view,单独立项)。 ---