#include "TileBasemap.hpp" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #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 kGroundZ = 0.0; // 底图置于 z=0 地面参考(剖面深度向下为负,落其下) constexpr double kZEps = 0.02; // 每层级 Z 微偏移:高层级压上面,避免共面瓦片 z-fighting constexpr int kHardCap = 400; // 瓦片硬上限:超过则即便未落地也强制清理,兜底内存 constexpr double kPi = 3.14159265358979323846; constexpr double kTerrainOpacity = 0.55; // 地形半透明:地下剖面可从任意角度透过地面看到(不再被遮挡) // 地面起伏:Mapbox terrain-RGB DEM 瓦片(原版 web 同款源,全球 CDN,比 AWS Terrarium 快)。 // 公式 elev(米) = -10000 + (R*65536 + G*256 + B)*0.1。数据到 z15,更高层级取祖先块。 // kMapboxToken:原版 commercial-admin 的 Mapbox 公开 token(pk.*,客户端用,同 天地图 tk 性质)。 const char* kMapboxToken = "pk.eyJ1IjoidGJ1c2FuIiwiYSI6ImNtZjY2emZneDBkY24ybXB4cmpvdmwzNWYifQ.h6tcQ380WN5AW6fZr08how"; constexpr int kDemMaxZoom = 15; constexpr int kTerrainGrid = 32; // 每瓦片网格分辨率(33x33 顶点) // key 打包:z<<44 | x<<22 | y(z≤18, x/y<2^18 < 2^22)。 void unpackKey(long long key, int& z, int& x, int& y) { z = static_cast(key >> 44); x = static_cast((key >> 22) & 0x3FFFFF); y = static_cast(key & 0x3FFFFF); } // QImage → vtkTexture:转 RGBA + 垂直翻转,使纹理 v=0 对应瓦片南边(与 PlaneSource tcoord 一致)。 vtkSmartPointer 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 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(vimg->GetScalarPointer(0, row, 0)); std::memcpy(dst, src, static_cast(w) * 4); } auto tex = vtkSmartPointer::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(std::lround(fx * (w - 1))), 0, w - 1); const int py = std::clamp(static_cast(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(z) << 44) | (static_cast(x) << 22) | static_cast(y); } TileBasemap::TileBasemap(geopro::render::Scene& scene, vtkRenderWindow* rw, std::shared_ptr frame, QObject* parent) : QObject(parent), scene_(scene), rw_(rw), frame_(std::move(frame)) {} 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() { 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::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(clientData)) self->refresh(); } void TileBasemap::enqueueGet(const QString& url, std::function 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::refineTile(int z, int x, int y, std::set& 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> 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(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 actor) { if (!actor) return; scene_.addActor(actor); placed_[key] = actor; } vtkSmartPointer TileBasemap::buildFlat(int z, int x, int y, vtkSmartPointer 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 = kGroundZ + (z - kMinZoom) * kZEps; // 高层级略抬高,压在旧层之上防共面闪烁 // PlaneSource 自动 tcoord:origin=SW→u 西0东1、v 南0北1(与翻转后纹理对齐)。 vtkNew plane; plane->SetOrigin(sw.x, sw.y, gz); plane->SetPoint1(se.x, se.y, gz); plane->SetPoint2(nw.x, nw.y, gz); vtkNew mapper; mapper->SetInputConnection(plane->GetOutputPort()); auto actor = vtkSmartPointer::New(); actor->SetMapper(mapper); actor->SetTexture(tex); actor->GetProperty()->LightingOff(); // 底图不受场景光照 actor->GetProperty()->SetOpacity(kTerrainOpacity); // 半透明:不遮挡地下剖面 // 注意:UseBounds 保持默认 true → 参与相机裁剪面计算,否则底图会被裁剪面"蒙版"切掉。 // 坐标轴/取景不被底图撑大,由 VtkSceneView 改用"数据自身包围盒"解决(非靠 UseBounds=false)。 return actor; } void TileBasemap::fetchTerrain(int z, int x, int y, long long key, vtkSmartPointer 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-RGB(pngraw 无损,保证高程解码准确);原版同源,全球 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 TileBasemap::buildWarped(int sz, int sx, int sy, int dz, int dx, int dy, vtkSmartPointer 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 = kGroundZ + (sz - kMinZoom) * kZEps; // PlaneSource(等距圆柱下平面插值即正确 x/y) + 自动 tcoord;再按各点真实经纬采 DEM 位移 Z。 vtkNew 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::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 mapper; mapper->SetInputData(warped); auto actor = vtkSmartPointer::New(); actor->SetMapper(mapper); actor->SetTexture(tex); actor->GetProperty()->LightingOff(); actor->GetProperty()->SetOpacity(kTerrainOpacity); // 半透明:不遮挡地下剖面 return actor; // UseBounds 默认 true:参与裁剪面,避免被"蒙版"切掉 } } // namespace geopro::app