geopro/src/app/TileBasemap.cpp

509 lines
22 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 <QDebug>
#include <QImage>
#include <QNetworkReply>
#include <QTimer>
#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 int kMaxConcurrent = 8; // 瓦片请求最大并发(防暴发饱和单域名连接)
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;
// 地面起伏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 顶点)
constexpr double kTerrainExag = 1.0; // 地形垂向夸张(1=真实高程)
// 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;
}
// 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)
: QObject(parent), scene_(scene), rw_(rw), frame_(std::move(frame)) {
refreshTimer_ = new QTimer(this);
refreshTimer_->setSingleShot(true);
connect(refreshTimer_, &QTimer::timeout, this, [this]() { refresh(); });
}
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<vtkCallbackCommand>::New();
observer_->SetClientData(this);
observer_->SetCallback(&TileBasemap::onInteractionEnd);
styleObs_->AddObserver(vtkCommand::EndInteractionEvent, observer_);
}
void TileBasemap::onInteractionEnd(vtkObject*, unsigned long, void* clientData, void*) {
// 防抖:交互(滚轮/旋转/平移)停止 ~140ms 后才刷新四叉树,避免每格事件都重算卡顿。
if (auto* self = static_cast<TileBasemap*>(clientData)) self->refreshTimer_->start(140);
}
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_ 跨隐藏-重选保留 → 重选地图秒出,不重拉。
haveBaseline_ = false; // 数据可能已换位置 → 重算基准高程
terrainProbed_ = false;
kind_ = kind;
if (kind == Hidden) {
if (rw_) rw_->Render();
return;
}
refresh(); // 四叉树覆盖:近细远粗一次铺满(含远处粗块,无需单独粗底层)
}
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 全在某视锥面外侧 → 不在视野内,直接丢弃(否则会在屏幕外乱细分耗尽预算)。
const double zmin = -1000.0, zmax = 1000.0; // 地形起伏远小于瓦片尺度,给宽松 z 带
for (int p = 0; p < 6; ++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)); // 瓦片地面尺寸(米)
// 该瓦片投影到屏幕的近似像素尺寸 > 阈值且未到最大层级 → 细分为 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))
}
if (screenPx > kTargetPx && z < kMaxZoom) {
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];
}
// 四叉树:从数据中心一圈粗根块出发,按屏幕误差细分 → 近细远粗、铺满视野,无单层级盲区。
desired_.clear();
int count = 0;
const auto c = frame_->toLatLon(0.0, 0.0); // 数据中心
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);
// 拉取缺失瓦片(旧层暂不删,落地后由 purgeStale 清理)。
for (long long key : desired_) {
if (placed_.count(key) || inFlight_.count(key)) continue;
int z, x, y;
unpackKey(key, z, x, y);
fetchTile(z, x, y, key);
}
purgeStale();
ren->ResetCameraClippingRange(); // 交互后扩裁剪面以含新载入的底图瓦片(防被"蒙版"切)
if (rw_) rw_->Render();
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 && rw_) rw_->Render();
}
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();
if (rw_) rw_->Render();
}
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 保持到瓦片最终落地(起伏/平面),使旧层在新块就位前不被清理 → 无空白闪烁。
QImage img;
const bool stale = (gen != generation_) || kind_ == Hidden ||
desired_.find(key) == desired_.end() || placed_.count(key);
const bool ok = !stale && reply->error() == QNetworkReply::NoError &&
img.loadFromData(reply->readAll());
if (!ok) {
inFlight_.erase(key);
purgeStale();
if (rw_) rw_->Render();
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();
if (rw_) rw_->Render();
}
});
}
void TileBasemap::placeActor(long long key, vtkSmartPointer<vtkActor> actor) {
if (!actor) return;
scene_.addActor(actor);
placed_[key] = actor;
}
void TileBasemap::ensureBaseline(int z, int x, int y, const QImage& dem) {
if (haveBaseline_) return;
// 基准必须锚"数据中心"的地面高程(确定性):只有含数据中心的块来定,按中心点采样。
// 否则四叉树里随便哪块先到都定基准 → 地形整体偏移 → 三维剖面相对地面忽上忽下。
const auto c = frame_->toLatLon(0.0, 0.0); // 数据中心经纬
const geopro::render::TileXY ct = geopro::render::lonLatToTile(c.lon, c.lat, z);
if (ct.x != x || ct.y != y) return; // 此块不含数据中心 → 不用它定基准
const geopro::render::LonLatBox b = geopro::render::tileBounds(z, x, y);
const double fx = (c.lon - b.west) / (b.east - b.west); // 数据中心在块内的列比例
const double fy = (b.north - c.lat) / (b.north - b.south); // 行比例(顶=北)
baseline_ = demElev(dem, fx, fy);
haveBaseline_ = true;
qInfo() << "[basemap] 地形启用 baseline(数据中心)=" << baseline_ << "m z=" << z;
}
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 = kGroundZ + (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(); // 底图不受场景光照
// 注意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()) {
ensureBaseline(dz, dx, dy, *dem); // 用含数据中心的 DEM 块定基准(确定性)
placeActor(key, buildWarped(z, x, y, dz, dx, dy, tex, *dem));
} else {
placeActor(key, buildFlat(z, x, y, tex)); // DEM 拉不到 → 平面兜底
}
inFlight_.erase(key);
purgeStale();
if (rw_) rw_->Render();
};
// 命中缓存:同一祖先 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();
if (rw_) rw_->Render();
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 = kGroundZ + (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 - baseline_) * kTerrainExag;
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();
return actor; // UseBounds 默认 true参与裁剪面避免被"蒙版"切掉
}
} // namespace geopro::app