plan: M1 Phase 1 core 纯逻辑层 实现计划(TDD)

This commit is contained in:
gaozheng 2026-06-07 17:51:58 +08:00
parent 308361d935
commit feab14de85
1 changed files with 652 additions and 0 deletions

View File

@ -0,0 +1,652 @@
# M1 Phase 1core 纯逻辑层 实现计划
> **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 量纲按来源**(网格色阶 0255、LVL 色阶 01)。
---
## Task 1core 静态库骨架 + 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 <gtest/gtest.h>
#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 2core 领域模型(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 <gtest/gtest.h>
#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 <vector>
#include <cstddef>
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<size_t>(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<double>& data() const { return data_; }
std::vector<double>& data() { return data_; }
private:
size_t idx(int i, int j, int k) const {
return (static_cast<size_t>(k) * ny_ + j) * nx_ + i;
}
int nx_, ny_, nz_;
std::vector<double> 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<size_t>(nx) * ny, 0.0) {}
int nx() const { return nx_; } int ny() const { return ny_; }
double& valueAt(int i, int j) { return values_[static_cast<size_t>(j) * nx_ + i]; }
double valueAt(int i, int j) const { return values_[static_cast<size_t>(j) * nx_ + i]; }
const std::vector<double>& values() const { return values_; }
std::vector<double>& values() { return values_; }
std::vector<double> x, y; // 轴坐标(规则)
private:
int nx_, ny_;
std::vector<double> values_;
};
// 散点场(剖面原数据):局部坐标 + 值。
struct ScatterField {
std::vector<double> 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 3core 阶梯色阶(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 <gtest/gtest.h>
#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); // 01 的 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 <string>
#include <vector>
#include <optional>
namespace geopro::core {
struct Rgba { unsigned char r, g, b, a; };
enum class AlphaScale { Unit, Bit255 }; // rgba alpha 量纲01 或 0255
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<Stop> stops_;
std::optional<Rgba> under_, over_, nan_;
};
} // namespace geopro::core
```
Create `src/core/model/ColorScale.cpp`:
```cpp
#include "model/ColorScale.hpp"
#include <algorithm>
#include <cmath>
#include <cstdio>
namespace geopro::core {
static unsigned char clampByte(double v) {
if (v < 0) v = 0; if (v > 255) v = 255; return static_cast<unsigned char>(v + 0.5);
}
Rgba parseColor(const std::string& s, AlphaScale alpha) {
if (!s.empty() && s[0] == '#') {
auto hex = [&](int i) { return static_cast<unsigned char>(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 4core 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 <gtest/gtest.h>
#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 <vector>
#include "model/Field.hpp"
namespace geopro::core {
struct PointSet { std::vector<double> 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 <cmath>
#include <limits>
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<double>::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<double>::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 5core 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 <gtest/gtest.h>
#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 <string>
#include <memory>
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> impl_;
};
} // namespace geopro::core
```
Create `src/core/geo/CrsTransform.cpp`:
```cpp
#include "geo/CrsTransform.hpp"
#include <proj.h>
#include <stdexcept>
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>()) {
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` 结论交叉校验。