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

23 KiB
Raw Blame History

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:[值, 颜色] 阶梯,取下界;颜色 #RRGGBBrgba(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:

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:

#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:

#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:

#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:

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: 提交
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:

#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:

#pragma once
#include <vector>
#include <cstddef>
namespace geopro::core {

// 规则三维标量场IInterpolator 输出render 层转 vtkImageDataclass 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: 提交
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:

#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:

#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:

#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.txtadd_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: 提交
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:

#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:

#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:

#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:

#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: 提交
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:

#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:

#pragma once
#include <string>
#include <memory>
namespace geopro::core {

struct Xy { double x, y; };

// PROJ 封装:源 CRS ↔ 目标 CRS。用于世界系↔GIS↔经纬度、影像异源 CRS 重投影(设计 §5class 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:

#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.cpptarget_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: 提交
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 结论交叉校验。