From feab14de85f3654717851d8cc9125955c1e052f5 Mon Sep 17 00:00:00 2001 From: gaozheng Date: Sun, 7 Jun 2026 17:51:58 +0800 Subject: [PATCH] =?UTF-8?q?plan:=20M1=20Phase=201=20core=20=E7=BA=AF?= =?UTF-8?q?=E9=80=BB=E8=BE=91=E5=B1=82=20=E5=AE=9E=E7=8E=B0=E8=AE=A1?= =?UTF-8?q?=E5=88=92(TDD)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plans/2026-06-07-m1-phase1-core.md | 652 ++++++++++++++++++ 1 file changed, 652 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-07-m1-phase1-core.md diff --git a/docs/superpowers/plans/2026-06-07-m1-phase1-core.md b/docs/superpowers/plans/2026-06-07-m1-phase1-core.md new file mode 100644 index 0000000..a09bf94 --- /dev/null +++ b/docs/superpowers/plans/2026-06-07-m1-phase1-core.md @@ -0,0 +1,652 @@ +# M1 Phase 1:core 纯逻辑层 实现计划 + +> **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:** 实现 `src/core/` 纯业务层(坐标系、领域模型、阶梯色阶、IDW 插值),零 Qt/零 VTK 依赖,全部 gtest 覆盖,以 `tools/validate_samples.py` 的真实样本结论为地面真值。 + +**Architecture:** 每个组件是一个 `core` 子库,只依赖标准库(+ Eigen / PROJ)。`core` 编译为静态库 `geopro_core`,被 tests 链接。本层产出后,Phase 2(data)解析器把样本填进这些模型,Phase 4(render)把 `ScalarVolume`/`ColorScale` 转 VTK。 + +**Tech Stack:** C++17 / Eigen / PROJ / GoogleTest。**不含 Qt、不含 VTK。** + +**前置:** Phase 0 三 spike 通过(工具链已验证)。本层逻辑不依赖 VTK/ADS,但编译依赖 vcpkg 依赖已构建完成。 + +**关键事实(来自数据核验,务必遵守):** +- GIS 为 EPSG:32649(UTM 49N):`projectX`≈516863=**Easting**,`projectY`≈2494259=**Northing**。样本里 `eastCoord/northCoord` 字段名与值**颠倒**,严禁信任字段名。 +- 网格 `v`/`z` 为 `[j=y][i=x]`,即外层 22(y)、内层 100(x)。 +- 网格规则:dx≈0.7094、dy≈0.7042 恒定。 +- colorBar:`[值, 颜色]` 阶梯,取下界;颜色 `#RRGGBB` 或 `rgba(r,g,b,a)`,**alpha 量纲按来源**(网格色阶 0–255、LVL 色阶 0–1)。 + +--- + +## Task 1:core 静态库骨架 + LocalFrame(坐标轴/原点/Z 基准) + +**Files:** +- Create: `src/core/CMakeLists.txt` +- Create: `src/core/geo/LocalFrame.hpp` +- Create: `src/core/geo/LocalFrame.cpp` +- Create: `tests/core/test_local_frame.cpp` +- Modify: `src/CMakeLists.txt`(启用 `add_subdirectory(core)`) +- Modify: `tests/CMakeLists.txt`(加 core 测试) + +- [ ] **Step 1: 建 core 库的 CMake** + +Create `src/core/CMakeLists.txt`: +```cmake +find_package(Eigen3 CONFIG REQUIRED) +add_library(geopro_core STATIC + geo/LocalFrame.cpp +) +target_include_directories(geopro_core PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) +target_link_libraries(geopro_core PUBLIC Eigen3::Eigen) +target_compile_features(geopro_core PUBLIC cxx_std_17) +# 铁律:core 不链接 Qt/VTK +``` +在 `src/CMakeLists.txt` 取消注释/新增 `add_subdirectory(core)`(置于 `add_subdirectory(app)` 之前)。 + +- [ ] **Step 2: 写失败测试** + +Create `tests/core/test_local_frame.cpp`: +```cpp +#include +#include "geo/LocalFrame.hpp" +using geopro::core::LocalFrame; + +TEST(LocalFrame, GisToWorldSubtractsOriginEastNorth) { + // 原点取某 UTM 点;projectX=Easting→world.x, projectY=Northing→world.y + LocalFrame f(/*originEast=*/516000.0, /*originNorth=*/2494000.0, /*zDatum=*/0.0); + auto w = f.gisToWorld(516863.6350992983, 2494259.56246985, 21.0); + EXPECT_NEAR(w.x, 863.6350992983, 1e-6); // East 偏移 + EXPECT_NEAR(w.y, 259.56246985, 1e-6); // North 偏移 + EXPECT_NEAR(w.z, 21.0, 1e-6); // Z 相对基准 +} + +TEST(LocalFrame, RoundTripWorldGis) { + LocalFrame f(516000.0, 2494000.0, 5.0); + auto w = f.gisToWorld(516500.0, 2494500.0, 30.0); + auto g = f.worldToGis(w); + EXPECT_NEAR(g.east, 516500.0, 1e-6); + EXPECT_NEAR(g.north, 2494500.0, 1e-6); + EXPECT_NEAR(g.elevation, 30.0, 1e-6); +} +``` + +- [ ] **Step 3: 运行测试,确认失败** + +Run: `cmake --build build/debug --target geopro_tests && ctest --test-dir build/debug -R LocalFrame --output-on-failure` +Expected: 编译失败(LocalFrame.hpp 不存在)。 + +- [ ] **Step 4: 实现 LocalFrame** + +Create `src/core/geo/LocalFrame.hpp`: +```cpp +#pragma once +namespace geopro::core { + +struct WorldPoint { double x, y, z; }; // 局部米:x=East偏移, y=North偏移, z=相对Z基准 +struct GisPoint { double east, north, elevation; }; // EPSG 投影米 + 高程 + +// 唯一权威「项目世界系」:以双精度 GIS 原点平移到局部米,规避 VTK float 大坐标抖动。 +// 轴向钉死:world.x = Easting(projectX),world.y = Northing(projectY),world.z 向上为正。 +class LocalFrame { +public: + LocalFrame(double originEast, double originNorth, double zDatum); + WorldPoint gisToWorld(double east, double north, double elevation) const; + GisPoint worldToGis(const WorldPoint& w) const; + double originEast() const { return originEast_; } + double originNorth() const { return originNorth_; } + double zDatum() const { return zDatum_; } +private: + double originEast_, originNorth_, zDatum_; +}; + +} // namespace geopro::core +``` + +Create `src/core/geo/LocalFrame.cpp`: +```cpp +#include "geo/LocalFrame.hpp" +namespace geopro::core { + +LocalFrame::LocalFrame(double originEast, double originNorth, double zDatum) + : originEast_(originEast), originNorth_(originNorth), zDatum_(zDatum) {} + +WorldPoint LocalFrame::gisToWorld(double east, double north, double elevation) const { + return WorldPoint{east - originEast_, north - originNorth_, elevation - zDatum_}; +} + +GisPoint LocalFrame::worldToGis(const WorldPoint& w) const { + return GisPoint{w.x + originEast_, w.y + originNorth_, w.z + zDatum_}; +} + +} // namespace geopro::core +``` + +在 `tests/CMakeLists.txt`(spike 之前)把 core 测试加入 `geopro_tests`: +```cmake +target_sources(geopro_tests PRIVATE core/test_local_frame.cpp) +target_link_libraries(geopro_tests PRIVATE geopro_core) +``` + +- [ ] **Step 5: 运行测试,确认通过** + +Run: `cmake --build build/debug --target geopro_tests && ctest --test-dir build/debug -R LocalFrame --output-on-failure` +Expected: 2 个用例 PASS。 + +- [ ] **Step 6: 提交** + +```powershell +git add src/core/ tests/core/test_local_frame.cpp src/CMakeLists.txt tests/CMakeLists.txt +git commit -m "feat(core): LocalFrame 坐标系(原点偏移+东北轴向+Z基准)" +``` + +--- + +## Task 2:core 领域模型(ScatterField / Grid / ScalarVolume / Anomaly) + +**Files:** +- Create: `src/core/model/Field.hpp`(纯数据结构) +- Create: `tests/core/test_model.cpp` +- Modify: `src/core/CMakeLists.txt`(头文件 only,无需新增 cpp;模型先做 header-only struct) + +- [ ] **Step 1: 写失败测试** + +Create `tests/core/test_model.cpp`: +```cpp +#include +#include "model/Field.hpp" +using namespace geopro::core; + +TEST(Model, ScalarVolumeIndexing) { + ScalarVolume v(/*nx=*/2, /*ny=*/3, /*nz=*/4); + v.at(1, 2, 3) = 42.0; + EXPECT_EQ(v.nx(), 2); EXPECT_EQ(v.ny(), 3); EXPECT_EQ(v.nz(), 4); + EXPECT_EQ(v.data().size(), 2u * 3u * 4u); + EXPECT_DOUBLE_EQ(v.at(1, 2, 3), 42.0); +} + +TEST(Model, GridStoresRegularSpec) { + Grid g(/*nx=*/100, /*ny=*/22); + EXPECT_EQ(g.values().size(), 100u * 22u); + g.valueAt(/*i=*/3, /*j=*/5) = 7.5; // i=x(0..99), j=y(0..21) + EXPECT_DOUBLE_EQ(g.valueAt(3, 5), 7.5); +} +``` + +- [ ] **Step 2: 运行,确认失败** + +Run: `cmake --build build/debug --target geopro_tests` +Expected: 编译失败(Field.hpp 不存在)。 + +- [ ] **Step 3: 实现模型** + +Create `src/core/model/Field.hpp`: +```cpp +#pragma once +#include +#include +namespace geopro::core { + +// 规则三维标量场(IInterpolator 输出;render 层转 vtkImageData)。 +class ScalarVolume { +public: + ScalarVolume(int nx, int ny, int nz) + : nx_(nx), ny_(ny), nz_(nz), data_(static_cast(nx) * ny * nz, 0.0) {} + int nx() const { return nx_; } int ny() const { return ny_; } int nz() const { return nz_; } + // 点序 i 最快、j 次之、k 最慢(匹配 vtkImageData) + double& at(int i, int j, int k) { return data_[idx(i, j, k)]; } + double at(int i, int j, int k) const { return data_[idx(i, j, k)]; } + const std::vector& data() const { return data_; } + std::vector& data() { return data_; } +private: + size_t idx(int i, int j, int k) const { + return (static_cast(k) * ny_ + j) * nx_ + i; + } + int nx_, ny_, nz_; + std::vector data_; +}; + +// 规则二维网格剖面(x=距离 i, y=深度 j)。values 存 [j*nx + i],i 最快。 +class Grid { +public: + Grid(int nx, int ny) : nx_(nx), ny_(ny), values_(static_cast(nx) * ny, 0.0) {} + int nx() const { return nx_; } int ny() const { return ny_; } + double& valueAt(int i, int j) { return values_[static_cast(j) * nx_ + i]; } + double valueAt(int i, int j) const { return values_[static_cast(j) * nx_ + i]; } + const std::vector& values() const { return values_; } + std::vector& values() { return values_; } + std::vector x, y; // 轴坐标(规则) +private: + int nx_, ny_; + std::vector values_; +}; + +// 散点场(剖面原数据):局部坐标 + 值。 +struct ScatterField { + std::vector x, y, z, v; // 与样本 xlist/ylist/(hlist)/vlist 对应 +}; + +} // namespace geopro::core +``` +(Task 2 不新增 cpp,`Field.hpp` 是 header-only;无需改 `src/core/CMakeLists.txt`,但确保 `geopro_core` 的 include 目录已 PUBLIC 暴露,Task 1 已做。) + +- [ ] **Step 4: 运行,确认通过** + +Run: `cmake --build build/debug --target geopro_tests && ctest --test-dir build/debug -R Model --output-on-failure` +Expected: 2 用例 PASS。 + +- [ ] **Step 5: 提交** + +```powershell +git add src/core/model/Field.hpp tests/core/test_model.cpp tests/CMakeLists.txt +git commit -m "feat(core): 领域模型 ScalarVolume/Grid/ScatterField(点序 i 最快)" +``` + +--- + +## Task 3:core 阶梯色阶(value→RGBA + 颜色解析 + alpha 来源) + +**Files:** +- Create: `src/core/model/ColorScale.hpp` +- Create: `src/core/model/ColorScale.cpp` +- Create: `tests/core/test_color_scale.cpp` +- Modify: `src/core/CMakeLists.txt`(加 ColorScale.cpp) + +- [ ] **Step 1: 写失败测试** + +Create `tests/core/test_color_scale.cpp`: +```cpp +#include +#include "model/ColorScale.hpp" +using namespace geopro::core; + +TEST(ColorScale, SteppedLookupTakesLowerStop) { + // stops: [0]=蓝, [10]=绿, [20]=红 + ColorScale cs; + cs.addStop(0.0, Rgba{0,0,255,255}); + cs.addStop(10.0, Rgba{0,255,0,255}); + cs.addStop(20.0, Rgba{255,0,0,255}); + EXPECT_EQ(cs.colorAt(5.0).g, 0); // [0,10) → 蓝 + EXPECT_EQ(cs.colorAt(5.0).b, 255); + EXPECT_EQ(cs.colorAt(15.0).r, 0); // [10,20) → 绿 + EXPECT_EQ(cs.colorAt(15.0).g, 255); +} + +TEST(ColorScale, UnderOverHandling) { + ColorScale cs; cs.addStop(0.0, Rgba{0,0,0,255}); cs.addStop(10.0, Rgba{255,255,255,255}); + cs.setUnder(Rgba{1,2,3,255}); cs.setOver(Rgba{4,5,6,255}); + EXPECT_EQ(cs.colorAt(-1.0).r, 1); // under + EXPECT_EQ(cs.colorAt(99.0).r, 4); // over +} + +TEST(ColorScale, ParseHexAndRgba) { + EXPECT_EQ(parseColor("#0000B3").b, 0xB3); + auto c255 = parseColor("rgba(0,0,170,255)", AlphaScale::Bit255); + EXPECT_EQ(c255.b, 170); EXPECT_EQ(c255.a, 255); + auto c1 = parseColor("rgba(0,0,170,1)", AlphaScale::Unit); + EXPECT_EQ(c1.b, 170); EXPECT_EQ(c1.a, 255); // 0–1 的 1.0 → 255 +} +``` + +- [ ] **Step 2: 运行,确认失败** + +Run: `cmake --build build/debug --target geopro_tests` +Expected: 编译失败(ColorScale.hpp 不存在)。 + +- [ ] **Step 3: 实现** + +Create `src/core/model/ColorScale.hpp`: +```cpp +#pragma once +#include +#include +#include +namespace geopro::core { + +struct Rgba { unsigned char r, g, b, a; }; +enum class AlphaScale { Unit, Bit255 }; // rgba alpha 量纲:0–1 或 0–255 + +Rgba parseColor(const std::string& s, AlphaScale alpha = AlphaScale::Bit255); + +// 阶梯色阶:值落 [stop_i, stop_{i+1}) 取 stop_i 的颜色(与平台一致)。 +class ColorScale { +public: + void addStop(double value, Rgba color); // 内部保持按 value 升序 + Rgba colorAt(double value) const; // 含 under/over/NaN 处理 + void setUnder(Rgba c) { under_ = c; } + void setOver(Rgba c) { over_ = c; } + void setNan(Rgba c) { nan_ = c; } + bool empty() const { return stops_.empty(); } +private: + struct Stop { double value; Rgba color; }; + std::vector stops_; + std::optional under_, over_, nan_; +}; + +} // namespace geopro::core +``` + +Create `src/core/model/ColorScale.cpp`: +```cpp +#include "model/ColorScale.hpp" +#include +#include +#include +namespace geopro::core { + +static unsigned char clampByte(double v) { + if (v < 0) v = 0; if (v > 255) v = 255; return static_cast(v + 0.5); +} + +Rgba parseColor(const std::string& s, AlphaScale alpha) { + if (!s.empty() && s[0] == '#') { + auto hex = [&](int i) { return static_cast(std::stoi(s.substr(i, 2), nullptr, 16)); }; + return Rgba{hex(1), hex(3), hex(5), 255}; + } + // rgba(r,g,b,a) / rgb(r,g,b) + double r = 0, g = 0, b = 0, a = 1; + auto p = s.find('('); + if (p != std::string::npos) std::sscanf(s.c_str() + p, "(%lf,%lf,%lf,%lf", &r, &g, &b, &a); + unsigned char av = (alpha == AlphaScale::Unit) ? clampByte(a * 255.0) : clampByte(a); + return Rgba{clampByte(r), clampByte(g), clampByte(b), av}; +} + +void ColorScale::addStop(double value, Rgba color) { + stops_.push_back({value, color}); + std::sort(stops_.begin(), stops_.end(), [](const Stop& x, const Stop& y) { return x.value < y.value; }); +} + +Rgba ColorScale::colorAt(double value) const { + if (std::isnan(value)) return nan_.value_or(Rgba{0, 0, 0, 0}); + if (stops_.empty()) return Rgba{0, 0, 0, 0}; + if (value < stops_.front().value) return under_.value_or(stops_.front().color); + if (value >= stops_.back().value) return over_.value_or(stops_.back().color); + // 取下界:最后一个 value<=查询值 的 stop + auto it = std::upper_bound(stops_.begin(), stops_.end(), value, + [](double v, const Stop& s) { return v < s.value; }); + return (it == stops_.begin() ? stops_.front() : *(it - 1)).color; +} + +} // namespace geopro::core +``` +在 `src/core/CMakeLists.txt` 的 `add_library(geopro_core ...)` 源文件列表加 `model/ColorScale.cpp`。 + +- [ ] **Step 4: 运行,确认通过** + +Run: `cmake --build build/debug --target geopro_tests && ctest --test-dir build/debug -R ColorScale --output-on-failure` +Expected: 3 用例 PASS。 + +- [ ] **Step 5: 提交** + +```powershell +git add src/core/model/ColorScale.* tests/core/test_color_scale.cpp src/core/CMakeLists.txt +git commit -m "feat(core): 阶梯色阶 colorAt+颜色解析(alpha 量纲按来源)" +``` + +--- + +## Task 4:core IDW 插值(IInterpolator → ScalarVolume,含包络裁剪) + +**Files:** +- Create: `src/core/algo/IInterpolator.hpp` +- Create: `src/core/algo/IdwInterpolator.hpp` +- Create: `src/core/algo/IdwInterpolator.cpp` +- Create: `tests/core/test_idw.cpp` +- Modify: `src/core/CMakeLists.txt`(加 IdwInterpolator.cpp) + +- [ ] **Step 1: 写失败测试** + +Create `tests/core/test_idw.cpp`: +```cpp +#include +#include "algo/IdwInterpolator.hpp" +using namespace geopro::core; + +TEST(Idw, ReproducesSampleAtNode) { + // 两个已知点,网格节点正好落在某点上 → 该节点值≈该点值 + PointSet pts; + pts.x = {0.0, 10.0}; pts.y = {0.0, 0.0}; pts.z = {0.0, 0.0}; pts.v = {100.0, 200.0}; + GridSpec spec{ /*nx*/3, /*ny*/1, /*nz*/1, /*ox*/0, /*oy*/0, /*oz*/0, /*dx*/5, /*dy*/1, /*dz*/1, + /*power*/2.0, /*maxDist*/1e9 }; + IdwInterpolator idw; + ScalarVolume vol = idw.interpolate(pts, spec); + EXPECT_NEAR(vol.at(0, 0, 0), 100.0, 1e-6); // (x=0) 命中点1 + EXPECT_NEAR(vol.at(2, 0, 0), 200.0, 1e-6); // (x=10) 命中点2 + EXPECT_GT(vol.at(1, 0, 0), 100.0); // 中点在两者之间 + EXPECT_LT(vol.at(1, 0, 0), 200.0); +} + +TEST(Idw, BlanksOutsideMaxDist) { + PointSet pts; pts.x = {0.0}; pts.y = {0.0}; pts.z = {0.0}; pts.v = {50.0}; + GridSpec spec{2,1,1, 0,0,0, 100,1,1, 2.0, /*maxDist*/1.0}; // 远点超出 maxDist + IdwInterpolator idw; + ScalarVolume vol = idw.interpolate(pts, spec); + EXPECT_NEAR(vol.at(0,0,0), 50.0, 1e-6); // 命中 + EXPECT_TRUE(std::isnan(vol.at(1,0,0))); // 超距 → NaN(blank) +} +``` + +- [ ] **Step 2: 运行,确认失败** + +Run: `cmake --build build/debug --target geopro_tests` +Expected: 编译失败(头文件不存在)。 + +- [ ] **Step 3: 实现** + +Create `src/core/algo/IInterpolator.hpp`: +```cpp +#pragma once +#include +#include "model/Field.hpp" +namespace geopro::core { + +struct PointSet { std::vector x, y, z, v; }; +struct GridSpec { + int nx, ny, nz; + double ox, oy, oz; // 原点 + double dx, dy, dz; // 步长 + double power; // IDW 幂 + double maxDist; // 超过则 blank(NaN),约束插值域(设计 §10) +}; + +class IInterpolator { +public: + virtual ~IInterpolator() = default; + virtual ScalarVolume interpolate(const PointSet& pts, const GridSpec& spec) const = 0; +}; + +} // namespace geopro::core +``` + +Create `src/core/algo/IdwInterpolator.hpp`: +```cpp +#pragma once +#include "algo/IInterpolator.hpp" +namespace geopro::core { +class IdwInterpolator : public IInterpolator { +public: + ScalarVolume interpolate(const PointSet& pts, const GridSpec& spec) const override; +}; +} // namespace geopro::core +``` + +Create `src/core/algo/IdwInterpolator.cpp`: +```cpp +#include "algo/IdwInterpolator.hpp" +#include +#include +namespace geopro::core { + +ScalarVolume IdwInterpolator::interpolate(const PointSet& pts, const GridSpec& s) const { + ScalarVolume vol(s.nx, s.ny, s.nz); + const double nan = std::numeric_limits::quiet_NaN(); + const size_t n = pts.v.size(); + for (int k = 0; k < s.nz; ++k) + for (int j = 0; j < s.ny; ++j) + for (int i = 0; i < s.nx; ++i) { + const double gx = s.ox + i * s.dx, gy = s.oy + j * s.dy, gz = s.oz + k * s.dz; + double wsum = 0.0, vsum = 0.0, nearest = std::numeric_limits::max(); + bool hit = false; double hitVal = 0.0; + for (size_t p = 0; p < n; ++p) { + const double ddx = gx - pts.x[p], ddy = gy - pts.y[p], ddz = gz - pts.z[p]; + const double d2 = ddx * ddx + ddy * ddy + ddz * ddz; + const double d = std::sqrt(d2); + if (d < nearest) nearest = d; + if (d < 1e-12) { hit = true; hitVal = pts.v[p]; break; } + const double w = 1.0 / std::pow(d, s.power); + wsum += w; vsum += w * pts.v[p]; + } + if (hit) vol.at(i, j, k) = hitVal; + else if (nearest > s.maxDist || wsum == 0.0) vol.at(i, j, k) = nan; // 包络外 blank + else vol.at(i, j, k) = vsum / wsum; + } + return vol; +} + +} // namespace geopro::core +``` +在 `src/core/CMakeLists.txt` 源列表加 `algo/IdwInterpolator.cpp`。 + +- [ ] **Step 4: 运行,确认通过** + +Run: `cmake --build build/debug --target geopro_tests && ctest --test-dir build/debug -R Idw --output-on-failure` +Expected: 2 用例 PASS。 + +- [ ] **Step 5: 提交** + +```powershell +git add src/core/algo/ tests/core/test_idw.cpp src/core/CMakeLists.txt +git commit -m "feat(core): IDW 插值器(IInterpolator→ScalarVolume, 含 maxDist 包络裁剪)" +``` + +--- + +## Task 5:core CrsTransform(PROJ 封装,多 CRS 互转) + +**Files:** +- Create: `src/core/geo/CrsTransform.hpp` +- Create: `src/core/geo/CrsTransform.cpp` +- Create: `tests/core/test_crs_transform.cpp` +- Modify: `src/core/CMakeLists.txt`(find_package PROJ + 链接 + 加 cpp) + +- [ ] **Step 1: 写失败测试** + +Create `tests/core/test_crs_transform.cpp`: +```cpp +#include +#include "geo/CrsTransform.hpp" +using namespace geopro::core; + +TEST(CrsTransform, Utm49nToWgs84RoundTrip) { + // EPSG:32649 → EPSG:4326,取样本附近一点(lon~114.16, lat~22.55) + CrsTransform t("EPSG:32649", "EPSG:4326"); + auto ll = t.forward(516868.0, 2494259.0); // (east,north) → (lon,lat) + EXPECT_NEAR(ll.x, 114.16, 0.05); // lon + EXPECT_NEAR(ll.y, 22.55, 0.05); // lat + auto en = t.inverse(ll.x, ll.y); + EXPECT_NEAR(en.x, 516868.0, 1.0); + EXPECT_NEAR(en.y, 2494259.0, 1.0); +} + +TEST(CrsTransform, WebMercatorToUtm) { + // 影像 EPSG:3857 → EPSG:32649(设计 §5:影像与剖面异源 CRS) + CrsTransform t("EPSG:3857", "EPSG:32649"); + auto p = t.forward(12708343.88, 2577685.90); // tfw 原点 + EXPECT_GT(p.x, 400000.0); EXPECT_LT(p.x, 600000.0); // 落在 UTM 49N 合理 Easting 区间 +} +``` + +- [ ] **Step 2: 运行,确认失败** + +Run: `cmake --build build/debug --target geopro_tests` +Expected: 编译失败(CrsTransform.hpp 不存在)。 + +- [ ] **Step 3: 实现** + +Create `src/core/geo/CrsTransform.hpp`: +```cpp +#pragma once +#include +#include +namespace geopro::core { + +struct Xy { double x, y; }; + +// PROJ 封装:源 CRS ↔ 目标 CRS。用于世界系↔GIS↔经纬度、影像异源 CRS 重投影(设计 §5)。 +class CrsTransform { +public: + CrsTransform(const std::string& srcCrs, const std::string& dstCrs); + ~CrsTransform(); + Xy forward(double x, double y) const; // src → dst + Xy inverse(double x, double y) const; // dst → src +private: + struct Impl; + std::unique_ptr impl_; +}; + +} // namespace geopro::core +``` + +Create `src/core/geo/CrsTransform.cpp`: +```cpp +#include "geo/CrsTransform.hpp" +#include +#include +namespace geopro::core { + +struct CrsTransform::Impl { + PJ_CONTEXT* ctx = nullptr; + PJ* pj = nullptr; +}; + +CrsTransform::CrsTransform(const std::string& src, const std::string& dst) + : impl_(std::make_unique()) { + impl_->ctx = proj_context_create(); + impl_->pj = proj_create_crs_to_crs(impl_->ctx, src.c_str(), dst.c_str(), nullptr); + if (!impl_->pj) throw std::runtime_error("CrsTransform: failed to create " + src + "->" + dst); + // 规范化为 (东/经度优先) 轴序,避免 PROJ 默认的 lat/lon 顺序坑 + PJ* norm = proj_normalize_for_visualization(impl_->ctx, impl_->pj); + if (norm) { proj_destroy(impl_->pj); impl_->pj = norm; } +} + +CrsTransform::~CrsTransform() { + if (impl_->pj) proj_destroy(impl_->pj); + if (impl_->ctx) proj_context_destroy(impl_->ctx); +} + +Xy CrsTransform::forward(double x, double y) const { + PJ_COORD c = proj_coord(x, y, 0, 0); + PJ_COORD r = proj_trans(impl_->pj, PJ_FWD, c); + return Xy{r.xy.x, r.xy.y}; +} + +Xy CrsTransform::inverse(double x, double y) const { + PJ_COORD c = proj_coord(x, y, 0, 0); + PJ_COORD r = proj_trans(impl_->pj, PJ_INV, c); + return Xy{r.xy.x, r.xy.y}; +} + +} // namespace geopro::core +``` +在 `src/core/CMakeLists.txt`:加 `find_package(PROJ CONFIG REQUIRED)`、源加 `geo/CrsTransform.cpp`、`target_link_libraries(geopro_core PUBLIC PROJ::proj)`。 + +- [ ] **Step 4: 运行,确认通过** + +Run: `cmake --build build/debug --target geopro_tests && ctest --test-dir build/debug -R CrsTransform --output-on-failure` +Expected: 2 用例 PASS(注:`proj_normalize_for_visualization` 后轴序为 x=经度/东、y=纬度/北)。 + +- [ ] **Step 5: 提交** + +```powershell +git add src/core/geo/CrsTransform.* tests/core/test_crs_transform.cpp src/core/CMakeLists.txt +git commit -m "feat(core): CrsTransform(PROJ 封装, UTM/WGS84/WebMercator 互转)" +``` + +--- + +## Self-Review 备注 + +- 覆盖:对应设计 §3(core 分层)、§5(LocalFrame 轴向/Z 基准、CrsTransform 多 CRS)、§6.1(点序 i 最快)、§7(阶梯色阶+alpha 来源)、§10(IInterpolator→ScalarVolume 去 VTK、maxDist 包络裁剪)。 +- 无占位:每步含完整代码与命令。 +- 类型一致:`ScalarVolume`/`Grid` 点序 `i` 最快贯穿 Task 2/4;`Rgba`/`AlphaScale`/`parseColor` 跨 Task 3 一致;`PointSet`/`GridSpec` 跨 Task 4 一致。 +- 铁律:core 仅依赖 std + Eigen + PROJ,**无 Qt/无 VTK**。 +- 后续:Phase 2(data 解析器)将真实样本填进这些模型并用 `tools/validate_samples.py` 结论交叉校验。