geopro/src/app/TileBasemap.cpp

584 lines
26 KiB
C++
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.

#include "TileBasemap.hpp"
#include <algorithm>
#include <cmath>
#include <cstring>
#include <utility>
#include <vector>
#include <QCryptographicHash>
#include <QDebug>
#include <QImage>
#include <QNetworkReply>
#include <QNetworkRequest>
#include <QString>
#include <QUrl>
#include <vtkActor.h>
#include <vtkCallbackCommand.h>
#include <vtkCamera.h>
#include <vtkCommand.h>
#include <vtkDataArray.h>
#include <vtkImageData.h>
#include <vtkInteractorObserver.h>
#include <vtkNew.h>
#include <vtkPlaneSource.h>
#include <vtkPointData.h>
#include <vtkPolyData.h>
#include <vtkPolyDataMapper.h>
#include <vtkPoints.h>
#include <vtkProperty.h>
#include <vtkRenderWindow.h>
#include <vtkRenderWindowInteractor.h>
#include <vtkRenderer.h>
#include <vtkTexture.h>
#include "Scene.hpp"
#include "geo/GeoLocalFrame.hpp"
#include "ground/TileMath.hpp"
namespace geopro::app {
namespace {
// 天地图 WMTS 令牌(与轨迹图 trajectory_map.html 同源)。
const char* kTk = "aca91d8c9f59a4f779f39061b8a07737";
constexpr int kRootZoom = 9; // 四叉树根层级(单块~78km±1 覆盖~234km 到天边)
constexpr double kTargetPx = 384.0; // 瓦片屏幕像素阈值:超过则细分(越小越清晰但块更多)
constexpr int kMaxLeaves = 200; // 一次覆盖的叶瓦片上限(安全兜底,防细分爆炸)
// 底图最大距离按剖面范围动态定:半径×倍数,夹在[下限,上限]。随勾选增删自动伸缩;无数据时用下限。
constexpr double kRangeFactor = 10.0;
constexpr double kRangeFloor = 2000.0; // 至少 2km(小剖面也有足够地理背景)
constexpr double kRangeCeil = 30000.0; // 最多 30km(防远裁剪面失控)
constexpr int kMaxConcurrent = 12; // 瓦片请求最大并发(天地图 8 子域+Mapbox适度提高吞吐)
constexpr int kMinZoom = 3;
constexpr int kMaxZoom = 18;
constexpr double kZEps = 0.02; // 每层级 Z 微偏移:高层级压上面,避免共面瓦片 z-fighting
constexpr int kHardCap = 400; // 瓦片硬上限:超过则即便未落地也强制清理,兜底内存
constexpr double kPi = 3.14159265358979323846;
// 地面起伏Mapbox terrain-RGB DEM 瓦片(原版 web 同款源,全球 CDN比 AWS Terrarium 快)。
// 公式 elev(米) = -10000 + (R*65536 + G*256 + B)*0.1。数据到 z15更高层级取祖先块。
// kMapboxToken原版 commercial-admin 的 Mapbox 公开 tokenpk.*,客户端用,同 天地图 tk 性质)。
const char* kMapboxToken =
"pk.eyJ1IjoidGJ1c2FuIiwiYSI6ImNtZjY2emZneDBkY24ybXB4cmpvdmwzNWYifQ.h6tcQ380WN5AW6fZr08how";
constexpr int kDemMaxZoom = 15;
constexpr int kTerrainGrid = 32; // 每瓦片网格分辨率(33x33 顶点)
// key 打包z<<44 | x<<22 | yz≤18, x/y<2^18 < 2^22
void unpackKey(long long key, int& z, int& x, int& y) {
z = static_cast<int>(key >> 44);
x = static_cast<int>((key >> 22) & 0x3FFFFF);
y = static_cast<int>(key & 0x3FFFFF);
}
// QImage → vtkTexture转 RGBA + 垂直翻转,使纹理 v=0 对应瓦片南边(与 PlaneSource tcoord 一致)。
vtkSmartPointer<vtkTexture> makeTexture(const QImage& img) {
const QImage rgba = img.convertToFormat(QImage::Format_RGBA8888);
const int w = rgba.width(), h = rgba.height();
if (w <= 0 || h <= 0) return nullptr;
vtkNew<vtkImageData> vimg;
vimg->SetDimensions(w, h, 1);
vimg->AllocateScalars(VTK_UNSIGNED_CHAR, 4);
for (int row = 0; row < h; ++row) {
const uchar* src = rgba.scanLine(h - 1 - row);
auto* dst = static_cast<uchar*>(vimg->GetScalarPointer(0, row, 0));
std::memcpy(dst, src, static_cast<size_t>(w) * 4);
}
auto tex = vtkSmartPointer<vtkTexture>::New();
tex->SetInputData(vimg);
tex->InterpolateOn(); // 双线性
tex->MipmapOn(); // 缩小/斜视不闪烁、不糊
tex->SetMaximumAnisotropicFiltering(16); // 斜视角下纹理保持清晰
tex->EdgeClampOn(); // 边缘夹紧,避免相邻瓦片接缝渗色
return tex;
}
// 天地图「此级别下,该区域无影像」固定占位 JPEG所有无影像瓦片字节完全一致(实测 size=4769、
// MD5 固定)。按 大小+MD5 精确识别 → 仅命中该占位图,绝不误判真实影像瓦片。
bool isTiandituNoImagery(const QByteArray& data) {
if (data.size() != 4769) return false; // 廉价预筛:仅对疑似占位大小算哈希
static const QByteArray kNoImageMd5 =
QByteArray::fromHex("c0edbdcb2c8ddd3e6a5cf09348c0fcb4");
return QCryptographicHash::hash(data, QCryptographicHash::Md5) == kNoImageMd5;
}
// Terrarium 像素解码高程:(fx,fy)∈[0,1]fy=0 北/顶行。
double demElev(const QImage& dem, double fx, double fy) {
const int w = dem.width(), h = dem.height();
if (w <= 0 || h <= 0) return 0.0;
const int px = std::clamp(static_cast<int>(std::lround(fx * (w - 1))), 0, w - 1);
const int py = std::clamp(static_cast<int>(std::lround(fy * (h - 1))), 0, h - 1);
const QRgb c = dem.pixel(px, py);
return -10000.0 + (qRed(c) * 65536.0 + qGreen(c) * 256.0 + qBlue(c)) * 0.1; // Mapbox terrain-RGB
}
} // namespace
long long TileBasemap::tileKey(int z, int x, int y) {
return (static_cast<long long>(z) << 44) | (static_cast<long long>(x) << 22) |
static_cast<long long>(y);
}
TileBasemap::TileBasemap(geopro::render::Scene& scene, vtkRenderWindow* rw,
std::shared_ptr<geopro::core::GeoLocalFrame> frame, QObject* parent,
double groundZ)
: QObject(parent), scene_(scene), rw_(rw), frame_(std::move(frame)), groundZ_(groundZ) {}
void TileBasemap::requestRender() {
// 合并渲染:同一事件循环轮次内的多次请求只渲染一帧(标准做法,避免逐瓦片重复 Render 卡顿)。
if (renderPending_) return;
renderPending_ = true;
QMetaObject::invokeMethod(
this,
[this]() {
renderPending_ = false;
// 渲染前更新裁剪面:把异步刚落地的瓦片纳入近/远裁剪范围,否则它们会被切(屏幕暗带)。
if (auto* ren = scene_.renderer()) ren->ResetCameraClippingRange();
if (rw_) rw_->Render();
},
Qt::QueuedConnection);
}
TileBasemap::~TileBasemap() {
// 移除本实例所有已贴瓦片:多实例(每 2D 平面一份)动态建销时,析构须撤回瓦片,否则渲染器仍持引用、
// 底图不随平面消失。共享 3D 底图存活至退出故旧码无此清理也无碍,但 per-plane 实例必须清。
if (auto* ren = scene_.renderer())
for (auto& kv : placed_) ren->RemoveViewProp(kv.second);
if (styleObs_ && observer_) styleObs_->RemoveObserver(observer_);
}
void TileBasemap::ensureObserver() {
if (styleObs_) return;
if (!rw_) return;
auto* iren = rw_->GetInteractor();
if (!iren) return;
auto* style = iren->GetInteractorStyle(); // EndInteractionEvent 由交互样式发出
if (!style) return;
styleObs_ = style;
observer_ = vtkSmartPointer<vtkCallbackCommand>::New();
observer_->SetClientData(this);
observer_->SetCallback(&TileBasemap::onInteractionEnd);
styleObs_->AddObserver(vtkCommand::EndInteractionEvent, observer_);
}
void TileBasemap::onInteractionEnd(vtkObject*, unsigned long, void* clientData, void*) {
if (auto* self = static_cast<TileBasemap*>(clientData)) self->refresh();
}
void TileBasemap::enqueueGet(const QString& url, std::function<void(QNetworkReply*)> onDone) {
netQueue_.push_back({url, std::move(onDone)});
pumpNetQueue();
}
void TileBasemap::pumpNetQueue() {
while (netInFlight_ < kMaxConcurrent && !netQueue_.empty()) {
const PendingGet req = std::move(netQueue_.front());
netQueue_.pop_front();
++netInFlight_;
QNetworkReply* reply = nam_.get(QNetworkRequest(QUrl(req.url)));
auto cb = req.cb;
connect(reply, &QNetworkReply::finished, this, [this, reply, cb]() {
cb(reply); // 回调内部 deleteLater + 处理
--netInFlight_;
pumpNetQueue();
});
}
}
void TileBasemap::hide() { show(Hidden); }
void TileBasemap::show(Kind kind) {
ensureObserver();
++generation_; // 旧回包(含换源前的层)按 generation 丢弃
for (auto& kv : placed_) scene_.renderer()->RemoveViewProp(kv.second);
placed_.clear();
inFlight_.clear();
netQueue_.clear(); // 丢弃换源前排队中的请求(在途的按 gen 自然作废)
desired_.clear();
// demCache_/texCache_ 跨隐藏-重选保留 → 重选地图秒出,不重拉。
terrainProbed_ = false;
satMaxZoom_ = kMaxZoom; // 新源/新区域:复位卫星层级上限,重新探测该区域影像覆盖深度
kind_ = kind;
if (kind == Hidden) {
requestRender();
return;
}
refresh(); // 四叉树覆盖:近细远粗一次铺满(地形按真实高程,与剖面同系)
}
void TileBasemap::setVerticalExaggeration(double ve) {
if (ve <= 0.0 || ve == ve_) return;
ve_ = ve;
if (kind_ != Hidden) show(kind_); // 重建地形(高程×新VE),与剖面 VE 保持一致
}
void TileBasemap::setOpacity(double o) {
o = std::clamp(o, 0.0, 1.0);
if (o == opacity_) return;
opacity_ = o;
if (kind_ != Hidden) show(kind_); // 重建套用新透明度
}
void TileBasemap::refineTile(int z, int x, int y, std::set<long long>& out, int& count) {
if (count >= kMaxLeaves) { out.insert(tileKey(z, x, y)); return; } // 安全上限:停止细分
const int n = 1 << z;
if (x < 0 || y < 0 || x >= n || y >= n) return;
const geopro::render::LonLatBox b = geopro::render::tileBounds(z, x, y);
const auto sw = frame_->toLocal(b.south, b.west);
const auto ne = frame_->toLocal(b.north, b.east);
// 视锥剔除:瓦片 AABB 全在某侧面外侧 → 不在视野内,丢弃(否则屏幕外乱细分耗尽预算)。
// 只用 4 个侧面(左右上下),不用近/远裁剪面——远裁剪面随已加载几何变化,
// 首帧底图未齐时远面贴得近会误剔除远处可见瓦片(等多久都不出、微动才出)。
const double zmin = -1000.0, zmax = 1000.0; // 地形起伏远小于瓦片尺度,给宽松 z 带
for (int p = 0; p < 4; ++p) {
const double* pl = &frustum_[p * 4]; // 内法向:内侧 a·x+b·y+c·z+d ≥ 0
const double vx = pl[0] >= 0 ? ne.x : sw.x; // 取最朝法向的角点(p-vertex)
const double vy = pl[1] >= 0 ? ne.y : sw.y;
const double vz = pl[2] >= 0 ? zmax : zmin;
if (pl[0] * vx + pl[1] * vy + pl[2] * vz + pl[3] < 0.0) return; // 全在外 → 剔除
}
const double cx = (sw.x + ne.x) * 0.5, cy = (sw.y + ne.y) * 0.5; // 瓦片中心(局部米)
const double g = std::max(std::abs(ne.x - sw.x), std::abs(ne.y - sw.y)); // 瓦片地面尺寸(米)
// 距离上限(按剖面范围动态):以覆盖中心(相机焦点 cenX_,cenY_)为心,瓦片离它太远则不加载——
// 远裁剪面有界(剖面不被近裁剪面切),也避免拉远无限铺。叶块本身可大于此距离(近端仍在范围内即保留)。
// 心改用焦点而非原点(0,0):否则 frame 锚在别处数据(如深圳)时,看台湾数据全被剔除→底图空。
const double rx = cx - cenX_, ry = cy - cenY_;
if (std::sqrt(rx * rx + ry * ry) - g * 0.5 > maxTileDist_) return;
// 该瓦片投影到屏幕的近似像素尺寸 > 阈值且未到最大层级 → 细分为 4 子块(近处更细)。
double screenPx;
if (projParallel_) {
screenPx = g * projK_; // 平行投影projK_ = H/(2·parallelScale)
} else {
const double dx = cx - camX_, dy = cy - camY_, dz = -camZ_; // 相对相机(瓦片 z≈0)
const double dist = std::max(1.0, std::sqrt(dx * dx + dy * dy + dz * dz));
screenPx = g * projK_ / dist; // 透视projK_ = H/(2·tan(vfov/2))
}
// 细分条件:屏幕上太大 → 细分(近细远粗);或瓦片本身比允许范围还大 → 也强制细分,
// 否则拉到最远时一块巨瓦(如 78km)正好盖住数据中心、过不了距离剔除 → 覆盖超大面积。
// 卫星层用「学习到的」上限 satMaxZoom_(无影像区域已降级),街道层仍到 kMaxZoom。
const int maxZ = (kind_ == Satellite) ? satMaxZoom_ : kMaxZoom;
if ((screenPx > kTargetPx || g > maxTileDist_) && z < maxZ) {
refineTile(z + 1, 2 * x, 2 * y, out, count);
refineTile(z + 1, 2 * x + 1, 2 * y, out, count);
refineTile(z + 1, 2 * x, 2 * y + 1, out, count);
refineTile(z + 1, 2 * x + 1, 2 * y + 1, out, count);
} else {
out.insert(tileKey(z, x, y));
++count;
}
}
void TileBasemap::refresh() {
if (kind_ == Hidden || refreshing_) return;
refreshing_ = true;
auto* ren = scene_.renderer();
auto* cam = ren ? ren->GetActiveCamera() : nullptr;
if (!cam) { refreshing_ = false; return; }
const int* sz = ren->GetSize();
const double H = (sz && sz[1] > 0) ? sz[1] : 800.0;
double camPos[3];
cam->GetPosition(camPos);
camX_ = camPos[0]; camY_ = camPos[1]; camZ_ = camPos[2];
projParallel_ = cam->GetParallelProjection();
projK_ = projParallel_ ? H / (2.0 * std::max(1.0, cam->GetParallelScale()))
: H / (2.0 * std::tan(cam->GetViewAngle() * 0.5 * kPi / 180.0));
const double aspect = (sz && sz[1] > 0) ? double(sz[0]) / double(sz[1]) : 1.0;
cam->GetFrustumPlanes(aspect, frustum_); // 6 视锥面(供 refineTile 剔除屏幕外瓦片)
// 用焦点(必在视锥内)统一各面方向为"内侧≥0",规避 VTK 法向内/外约定差异(否则可能全剔成黑屏)。
double fp[3];
cam->GetFocalPoint(fp);
for (int p = 0; p < 6; ++p) {
double* pl = &frustum_[p * 4];
if (pl[0] * fp[0] + pl[1] * fp[1] + pl[2] * fp[2] + pl[3] < 0.0)
for (int k = 0; k < 4; ++k) pl[k] = -pl[k];
}
// 底图覆盖中心 = 相机焦点(用户正看处)的局部 XY而非世界原点frame 锚在首个数据集,看远处别处
// 数据时原点离视野很远会把全部瓦片距离剔除→底图空。焦点为心则底图随视野走(同 frame 仍与数据对齐)。
cenX_ = fp[0];
cenY_ = fp[1];
// 底图最大距离按当前剖面合并范围动态定(随勾选增删自动伸缩);无数据用下限。
maxTileDist_ = kRangeFloor;
if (dataRadiusProvider_) {
const double r = dataRadiusProvider_();
if (r > 0.0) maxTileDist_ = std::clamp(r * kRangeFactor, kRangeFloor, kRangeCeil);
}
// 四叉树:从覆盖中心(相机焦点经纬)一圈粗根块出发,按屏幕误差细分 → 近细远粗、铺满视野,无盲区。
desired_.clear();
int count = 0;
const auto c = frame_->toLatLon(cenX_, cenY_); // 覆盖中心 = 相机焦点(非世界原点)
const geopro::render::TileXY root = geopro::render::lonLatToTile(c.lon, c.lat, kRootZoom);
for (int dy = -1; dy <= 1; ++dy)
for (int dx = -1; dx <= 1; ++dx)
refineTile(kRootZoom, root.x + dx, root.y + dy, desired_, count);
// 拉取缺失瓦片:按离相机距离排序,最近的先拉 → 用户正看的区域最先出现(而非粗/远块先出)。
std::vector<std::pair<double, long long>> todo;
for (long long key : desired_) {
if (placed_.count(key) || inFlight_.count(key)) continue;
int z, x, y;
unpackKey(key, z, x, y);
const geopro::render::LonLatBox b = geopro::render::tileBounds(z, x, y);
const auto sw = frame_->toLocal(b.south, b.west);
const auto ne = frame_->toLocal(b.north, b.east);
const double cx = (sw.x + ne.x) * 0.5 - camX_, cy = (sw.y + ne.y) * 0.5 - camY_;
todo.push_back({cx * cx + cy * cy, key});
}
std::sort(todo.begin(), todo.end());
for (const auto& t : todo) {
int z, x, y;
unpackKey(t.second, z, x, y);
fetchTile(z, x, y, t.second);
}
purgeStale();
ren->ResetCameraClippingRange(); // 交互后扩裁剪面以含新载入的底图瓦片(防被"蒙版"切)
requestRender();
refreshing_ = false;
}
void TileBasemap::purgeStale() {
// 仅当本轮所有请求都落地(inFlight 空)后再删旧层;否则缩放/平移期间老瓦片留作回退,避免空白闪烁。
// 超过硬上限则强制清理兜底内存(可能短暂空白,极少触发)。
if (!inFlight_.empty() && placed_.size() <= static_cast<size_t>(kHardCap)) return;
bool removed = false;
for (auto it = placed_.begin(); it != placed_.end();) {
if (desired_.find(it->first) == desired_.end()) {
scene_.renderer()->RemoveViewProp(it->second);
it = placed_.erase(it);
removed = true;
} else {
++it;
}
}
if (removed) requestRender();
}
void TileBasemap::fetchTile(int z, int x, int y, long long key) {
// 命中影像缓存 → 不走网络直接落地DEM 多半也已缓存)。重选地图/缩放回看即秒出。
auto cit = texCache_.find(key);
if (cit != texCache_.end()) {
inFlight_.insert(key);
auto tex = cit->second;
if (kind_ == Satellite) {
fetchTerrain(z, x, y, key, tex);
} else {
placeActor(key, buildFlat(z, x, y, tex));
inFlight_.erase(key);
purgeStale();
requestRender();
}
return;
}
const QString layerDir = (kind_ == Satellite) ? QStringLiteral("img_w") : QStringLiteral("vec_w");
const QString layer = (kind_ == Satellite) ? QStringLiteral("img") : QStringLiteral("vec");
const int sub = (x + y) % 8; // 子域负载分担 t0-t7
const QString url =
QStringLiteral("http://t%1.tianditu.gov.cn/%2/wmts?service=wmts&request=GetTile"
"&version=1.0.0&LAYER=%3&tileMatrixSet=w&TileMatrix=%4&TileRow=%5"
"&TileCol=%6&style=default&format=tiles&tk=%7")
.arg(sub)
.arg(layerDir, layer)
.arg(z)
.arg(y)
.arg(x)
.arg(QString::fromLatin1(kTk));
const int gen = generation_;
inFlight_.insert(key);
enqueueGet(url, [this, key, z, x, y, gen](QNetworkReply* reply) {
reply->deleteLater();
// inFlight 保持到瓦片最终落地(起伏/平面),使旧层在新块就位前不被清理 → 无空白闪烁。
const bool stale = (gen != generation_) || kind_ == Hidden ||
desired_.find(key) == desired_.end() || placed_.count(key);
const QByteArray data =
(!stale && reply->error() == QNetworkReply::NoError) ? reply->readAll() : QByteArray();
// 天地图无影像占位图:该区域此层级无卫星影像 → 学习把卫星上限降到 z-1 并重铺(改用父层真实
// 影像放大覆盖),不缓存/不落地占位图。仅卫星层适用(街道矢量层全球到 z18 无此占位)。
if (kind_ == Satellite && !data.isEmpty() && isTiandituNoImagery(data)) {
inFlight_.erase(key);
if (z - 1 < satMaxZoom_) {
satMaxZoom_ = z - 1;
purgeStale();
refresh(); // 以新上限重铺该区域
} else {
purgeStale();
requestRender();
}
return;
}
QImage img;
const bool ok = !data.isEmpty() && img.loadFromData(data);
if (!ok) {
inFlight_.erase(key);
purgeStale();
requestRender();
return;
}
auto tex = makeTexture(img);
if (texCache_.size() > 1200) texCache_.clear(); // 兜底内存;在用纹理由 actor 自身保活
texCache_[key] = tex; // 缓存供重选/缩放回看复用
if (kind_ == Satellite) {
fetchTerrain(z, x, y, key, tex); // 拉 DEM 后直接落地起伏块(inFlight 续到那时)
} else {
placeActor(key, buildFlat(z, x, y, tex)); // 街道图无地形 → 直接平面
inFlight_.erase(key);
purgeStale();
requestRender();
}
});
}
void TileBasemap::placeActor(long long key, vtkSmartPointer<vtkActor> actor) {
if (!actor) return;
scene_.addActor(actor);
placed_[key] = actor;
}
vtkSmartPointer<vtkActor> TileBasemap::buildFlat(int z, int x, int y,
vtkSmartPointer<vtkTexture> tex) {
const geopro::render::LonLatBox b = geopro::render::tileBounds(z, x, y);
const auto sw = frame_->toLocal(b.south, b.west);
const auto se = frame_->toLocal(b.south, b.east);
const auto nw = frame_->toLocal(b.north, b.west);
const double gz = groundZ_ + (z - kMinZoom) * kZEps; // 高层级略抬高,压在旧层之上防共面闪烁
// PlaneSource 自动 tcoordorigin=SW→u 西0东1、v 南0北1与翻转后纹理对齐
vtkNew<vtkPlaneSource> plane;
plane->SetOrigin(sw.x, sw.y, gz);
plane->SetPoint1(se.x, se.y, gz);
plane->SetPoint2(nw.x, nw.y, gz);
vtkNew<vtkPolyDataMapper> mapper;
mapper->SetInputConnection(plane->GetOutputPort());
auto actor = vtkSmartPointer<vtkActor>::New();
actor->SetMapper(mapper);
actor->SetTexture(tex);
actor->GetProperty()->LightingOff(); // 底图不受场景光照
actor->GetProperty()->SetOpacity(opacity_); // 半透明:不遮挡地下剖面
// 注意UseBounds 保持默认 true → 参与相机裁剪面计算,否则底图会被裁剪面"蒙版"切掉。
// 坐标轴/取景不被底图撑大,由 VtkSceneView 改用"数据自身包围盒"解决(非靠 UseBounds=false
return actor;
}
void TileBasemap::fetchTerrain(int z, int x, int y, long long key, vtkSmartPointer<vtkTexture> tex) {
// Terrarium 数据约到 z15更高层级取覆盖本块的祖先 DEM 瓦片,按经纬采样其子区域。
const int dz = std::min(z, kDemMaxZoom);
const int shift = z - dz;
const int dx = x >> shift, dy = y >> shift;
const long long demKey = tileKey(dz, dx, dy);
// 落地一块瓦片DEM 有效→起伏,否则→平面兜底;并推进 inFlight/清理。
auto place = [this, key, z, x, y, dz, dx, dy, tex](const QImage* dem) {
if (dem && !dem->isNull()) {
placeActor(key, buildWarped(z, x, y, dz, dx, dy, tex, *dem));
} else {
placeActor(key, buildFlat(z, x, y, tex)); // DEM 拉不到 → 平面兜底
}
inFlight_.erase(key);
purgeStale();
requestRender();
};
// 命中缓存:同一祖先 DEM 块的多个瓦片瞬间起伏,免重复网络。
auto cached = demCache_.find(demKey);
if (cached != demCache_.end()) {
place(&cached->second);
return;
}
if (!terrainProbed_) {
terrainProbed_ = true;
qInfo() << "[basemap] 首次拉DEM 卫星z=" << z << " → DEMz=" << dz << "(" << dx << "," << dy
<< ")";
}
// Mapbox terrain-RGBpngraw 无损,保证高程解码准确);原版同源,全球 CDN。
const QString url =
QStringLiteral("https://api.mapbox.com/v4/mapbox.terrain-rgb/%1/%2/%3.pngraw?access_token=%4")
.arg(dz)
.arg(dx)
.arg(dy)
.arg(QString::fromLatin1(kMapboxToken));
const int gen = generation_;
enqueueGet(url, [this, key, demKey, gen, place](QNetworkReply* reply) {
reply->deleteLater();
if (gen != generation_ || kind_ != Satellite ||
desired_.find(key) == desired_.end() || placed_.count(key)) {
inFlight_.erase(key); // 过期/移出视野 → 不落地
purgeStale();
requestRender();
return;
}
QImage dem;
if (reply->error() != QNetworkReply::NoError) {
qWarning() << "[basemap] DEM 拉取失败(降级平面)" << reply->url().toString()
<< reply->errorString();
} else if (!dem.loadFromData(reply->readAll())) {
qWarning() << "[basemap] DEM 解码失败(降级平面)" << reply->url().toString();
dem = QImage();
} else {
demCache_[demKey] = dem; // 缓存供同祖先块复用
}
place(dem.isNull() ? nullptr : &dem);
});
}
vtkSmartPointer<vtkActor> TileBasemap::buildWarped(int sz, int sx, int sy, int dz, int dx, int dy,
vtkSmartPointer<vtkTexture> tex,
const QImage& dem) {
const geopro::render::LonLatBox sb = geopro::render::tileBounds(sz, sx, sy); // 卫星块(几何)
const geopro::render::LonLatBox db = geopro::render::tileBounds(dz, dx, dy); // DEM 块(采样)
const auto sw = frame_->toLocal(sb.south, sb.west);
const auto se = frame_->toLocal(sb.south, sb.east);
const auto nw = frame_->toLocal(sb.north, sb.west);
const double base = groundZ_ + (sz - kMinZoom) * kZEps;
// PlaneSource(等距圆柱下平面插值即正确 x/y) + 自动 tcoord再按各点真实经纬采 DEM 位移 Z。
vtkNew<vtkPlaneSource> plane;
plane->SetOrigin(sw.x, sw.y, base);
plane->SetPoint1(se.x, se.y, base);
plane->SetPoint2(nw.x, nw.y, base);
plane->SetResolution(kTerrainGrid, kTerrainGrid);
plane->Update();
auto warped = vtkSmartPointer<vtkPolyData>::New();
warped->DeepCopy(plane->GetOutput());
vtkDataArray* tc = warped->GetPointData()->GetTCoords();
vtkPoints* pts = warped->GetPoints();
const double sLonSpan = sb.east - sb.west, sLatSpan = sb.north - sb.south;
const double dLonSpan = db.east - db.west, dLatSpan = db.north - db.south;
const vtkIdType n = pts->GetNumberOfPoints();
for (vtkIdType id = 0; id < n; ++id) {
double t[2];
tc->GetTuple(id, t); // u:西0东1, v:南0北1
const double lon = sb.west + t[0] * sLonSpan;
const double lat = sb.south + t[1] * sLatSpan;
const double fx = (lon - db.west) / dLonSpan; // DEM 块内列比例
const double fy = (db.north - lat) / dLatSpan; // DEM 顶行=北 → fy
const double elev = demElev(dem, fx, fy);
double p[3];
pts->GetPoint(id, p);
p[2] = base + elev * ve_; // 真实高程×垂直夸张:与剖面(同样真实高程×VE)同系对齐
pts->SetPoint(id, p);
}
pts->Modified();
vtkNew<vtkPolyDataMapper> mapper;
mapper->SetInputData(warped);
auto actor = vtkSmartPointer<vtkActor>::New();
actor->SetMapper(mapper);
actor->SetTexture(tex);
actor->GetProperty()->LightingOff();
actor->GetProperty()->SetOpacity(opacity_); // 半透明:不遮挡地下剖面
return actor; // UseBounds 默认 true参与裁剪面避免被"蒙版"切掉
}
} // namespace geopro::app