geopro/docs/superpowers/plans/2026-06-07-m1-phase1-core.md

653 lines
23 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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` 结论交叉校验。