feat/vtk-3d-view #7

Merged
gaozheng merged 301 commits from feat/vtk-3d-view into main 2026-06-27 18:43:52 +08:00
6 changed files with 225 additions and 77 deletions
Showing only changes of commit c03dc35469 - Show all commits

View File

@ -6,6 +6,7 @@
#include <vtkActor.h>
#include <vtkCubeAxesActor.h>
#include <vtkProp.h>
#include <vtkRenderWindow.h>
#include <vtkRenderer.h>
#include <vtkVolume.h>
@ -61,11 +62,24 @@ VtkSceneView::VtkSceneView(geopro::render::Scene& scene, vtkRenderWindow* render
frame_(std::move(frame)),
zRefElev_(zRefElev) {}
void VtkSceneView::removeProps(std::vector<vtkSmartPointer<vtkProp>>& props) {
for (auto& p : props)
if (p) scene_.renderer()->RemoveViewProp(p);
props.clear();
}
void VtkSceneView::clear() {
scene_.clear(); // RemoveAllViewProps连同坐标轴一并移除
currentAxes_ = nullptr; // 旧坐标轴已随 clear 移除,置空避免悬留引用
// 只移除数据 prop按 ds 跟踪)+ 杂项(地形/测线)+ 坐标轴;不动底图(TileBasemap 自管)→ 重建不丢图。
for (auto& kv : dsProps_) removeProps(kv.second);
dsProps_.clear();
removeProps(miscProps_);
if (currentAxes_) {
scene_.renderer()->RemoveViewProp(currentAxes_);
currentAxes_ = nullptr;
}
// 体素 image 失效:置空并通知上层关闭切片(防切片附着到已移除的 image
currentVolumeImage_ = nullptr;
volumeOwnerDs_.clear();
frameAnchoredToData_ = false; // 新一轮选择重新按其首个真实剖面重锚原点
if (onVolumeChanged) onVolumeChanged();
}
@ -74,10 +88,14 @@ void VtkSceneView::setVerticalExaggeration(double ve) { verticalExaggeration_ =
void VtkSceneView::addSurveyLine(const geopro::core::Grid& grid) {
auto line = geopro::render::buildSurveyLine(grid, *frame_);
if (line) scene_.addActor(line);
if (line) {
scene_.addActor(line);
miscProps_.push_back(line);
}
}
void VtkSceneView::addCurtain(const geopro::core::Grid& grid, const geopro::core::ColorScale& cs) {
void VtkSceneView::addCurtain(const std::string& dsId, const geopro::core::Grid& grid,
const geopro::core::ColorScale& cs) {
// 首个带经纬度的剖面到达 → 把 GeoLocalFrame 原点重锚到该剖面 lat/lon 中心:使局部坐标从 0 附近起
// (轴刻度有意义),同一选择内多条剖面共用此原点 → 相互地理配准。无经纬剖面是平面、不受原点影响。
const int nx = grid.nx();
@ -91,15 +109,18 @@ void VtkSceneView::addCurtain(const geopro::core::Grid& grid, const geopro::core
// 就地重锚共享 frame不换对象→ 同持此 frame 的底图层等随即一致对齐。
frame_->reanchor((la0 + la1) / 2.0, (lo0 + lo1) / 2.0);
frameAnchoredToData_ = true;
if (onFrameReanchored) onFrameReanchored(); // 通知底图刷新到数据位置
}
auto curtain = geopro::render::buildCurtain(grid, cs, *frame_);
if (curtain) {
curtain->SetScale(1.0, 1.0, verticalExaggeration_); // 纵向夸张成墙
scene_.addActor(curtain);
dsProps_[dsId].push_back(curtain);
}
}
void VtkSceneView::addVolume(const geopro::data::VolumeGrid& vol, const geopro::core::ColorScale& cs) {
void VtkSceneView::addVolume(const std::string& dsId, const geopro::data::VolumeGrid& vol,
const geopro::core::ColorScale& cs) {
// 纵向夸张烤进 image 的 z 原点/间距(与帘面 SetScale 同倍,保证纵向一致)。
// 用暴露 image 的 buildVoxel 重载:保留 currentVolumeImage_ 供 P3 切片附着(几何含 VE
vtkSmartPointer<vtkImageData> image;
@ -109,10 +130,12 @@ void VtkSceneView::addVolume(const geopro::data::VolumeGrid& vol, const geopro::
image);
if (volume) {
scene_.addViewProp(volume);
dsProps_[dsId].push_back(volume);
currentVolumeImage_ = image;
currentColorScale_ = cs;
currentVmin_ = vol.vmin;
currentVmax_ = vol.vmax;
volumeOwnerDs_ = dsId;
if (onVolumeChanged) onVolumeChanged();
}
}
@ -120,7 +143,22 @@ void VtkSceneView::addVolume(const geopro::data::VolumeGrid& vol, const geopro::
void VtkSceneView::addTerrain(const geopro::data::TerrainPaths& paths) {
auto terrain = geopro::render::buildTerrain(paths.demPath, paths.imagePath, *frame_, zRefElev_,
verticalExaggeration_);
if (terrain) scene_.addActor(terrain);
if (terrain) {
scene_.addActor(terrain);
miscProps_.push_back(terrain);
}
}
void VtkSceneView::removeDataset(const std::string& dsId) {
auto it = dsProps_.find(dsId);
if (it == dsProps_.end()) return;
removeProps(it->second);
dsProps_.erase(it);
if (volumeOwnerDs_ == dsId) { // 该 ds 的体素被移除 → 切片源失效
currentVolumeImage_ = nullptr;
volumeOwnerDs_.clear();
if (onVolumeChanged) onVolumeChanged();
}
}
void VtkSceneView::setAxes(geopro::controller::AxesMode mode, geopro::controller::AxesUnit unit,
@ -183,4 +221,10 @@ void VtkSceneView::render(bool is2D) {
if (renderWindow_) renderWindow_->Render();
}
void VtkSceneView::renderIncremental() {
// 增量渲染:仅按新包围盒重建坐标轴并提交,不动相机(勾选/取消时视角不跳)。
rebuildAxes();
if (renderWindow_) renderWindow_->Render();
}
} // namespace geopro::app

View File

@ -1,6 +1,9 @@
#pragma once
#include <functional>
#include <map>
#include <memory>
#include <string>
#include <vector>
#include <vtkCubeAxesActor.h>
#include <vtkImageData.h>
@ -13,6 +16,7 @@ namespace geopro::core { class GeoLocalFrame; }
namespace geopro::render { class Scene; }
class vtkRenderer;
class vtkRenderWindow;
class vtkProp;
namespace geopro::app {
@ -29,15 +33,19 @@ public:
void clear() override;
void setVerticalExaggeration(double ve) override;
void addSurveyLine(const geopro::core::Grid& grid) override;
void addCurtain(const geopro::core::Grid& grid, const geopro::core::ColorScale& cs) override;
void addVolume(const geopro::data::VolumeGrid& vol, const geopro::core::ColorScale& cs) override;
void addCurtain(const std::string& dsId, const geopro::core::Grid& grid,
const geopro::core::ColorScale& cs) override;
void addVolume(const std::string& dsId, const geopro::data::VolumeGrid& vol,
const geopro::core::ColorScale& cs) override;
void addTerrain(const geopro::data::TerrainPaths& paths) override;
void removeDataset(const std::string& dsId) override;
void setAxes(geopro::controller::AxesMode mode, geopro::controller::AxesUnit unit,
int fontSize) override;
void applyCameraView(geopro::controller::ViewDir dir) override;
void zoom(double factor) override;
void fitView() override;
void render(bool is2D) override;
void renderIncremental() override;
// ── P3 切片交互:暴露当前体素 image含 VE 烤入的 origin/spacing供切片附着 ──
// addVolume 用暴露 image 的 buildVoxel 重载保留clear/无体素时置空。
@ -51,9 +59,13 @@ public:
// InteractionManager重附着或关闭切片。clear 时以 nullptr 触发。
std::function<void()> onVolumeChanged;
// frame 原点重锚(首个带经纬剖面到达)后回调,供底图等随之刷新到数据所在位置。
std::function<void()> onFrameReanchored;
private:
// 按当前坐标轴设置 + 场景包围盒重建坐标轴 proprender 末尾调)。
void rebuildAxes();
void removeProps(std::vector<vtkSmartPointer<vtkProp>>& props); // 从 renderer 移除并清空
geopro::render::Scene& scene_;
vtkRenderWindow* renderWindow_;
@ -77,6 +89,12 @@ private:
geopro::core::ColorScale currentColorScale_;
double currentVmin_ = 0.0;
double currentVmax_ = 0.0;
// 增量渲染:按 dsId 跟踪该数据集的 props帘面/体素),支持单独移除而不全量重建;
// miscProps_ 为非数据集 prop地形/测线),仅随 clear 全量移除。底图由 TileBasemap 自管、不在此。
std::map<std::string, std::vector<vtkSmartPointer<vtkProp>>> dsProps_;
std::vector<vtkSmartPointer<vtkProp>> miscProps_;
std::string volumeOwnerDs_; // 当前 currentVolumeImage_ 归属的 ds其被移除时置空切片源
};
} // namespace geopro::app

View File

@ -1,4 +1,6 @@
#pragma once
#include <string>
#include "model/ColorScale.hpp"
#include "model/Field.hpp"
#include "repo/I3dSceneRepository.hpp"
@ -25,14 +27,16 @@ public:
// 2D俯视测线红线z=0
virtual void addSurveyLine(const geopro::core::Grid& grid) = 0;
// 3D竖直帘面grid + colorScale 着色)
virtual void addCurtain(const geopro::core::Grid& grid,
// 3D竖直帘面grid + colorScale 着色);按 dsId 跟踪以支持增量移除
virtual void addCurtain(const std::string& dsId, const geopro::core::Grid& grid,
const geopro::core::ColorScale& cs) = 0;
// 3D体绘制IDW 体素 + colorScale
virtual void addVolume(const geopro::data::VolumeGrid& vol,
// 3D体绘制IDW 体素 + colorScale;按 dsId 跟踪
virtual void addVolume(const std::string& dsId, const geopro::data::VolumeGrid& vol,
const geopro::core::ColorScale& cs) = 0;
// 3DDEM 地形 + 影像纹理。
virtual void addTerrain(const geopro::data::TerrainPaths& paths) = 0;
// 增量移除某数据集的全部图元(取消勾选时调,不影响其余 ds 与底图)。
virtual void removeDataset(const std::string& dsId) = 0;
// 坐标轴设置P2显示方式 + 刻度单位 + 字号。视图据当前场景包围盒重建坐标轴 prop。
// None 模式 = 移除坐标轴rebuild 时由控制器在 clear 后重新下发当前坐标轴设置。
@ -45,8 +49,10 @@ public:
// 适配全览P2ResetCamera 并提交渲染。
virtual void fitView() = 0;
// 应用相机预设2D 俯视 / 3D 自由)并提交渲染
// 应用相机预设2D 俯视 / 3D 自由)并提交渲染(全量重建用,会 ResetCamera
virtual void render(bool is2D) = 0;
// 增量提交渲染:重建坐标轴并刷新,但不动相机(勾选/取消单个 ds 时视角不跳)。
virtual void renderIncremental() = 0;
};
} // namespace geopro::controller

View File

@ -1,5 +1,7 @@
#include "VtkSceneController.hpp"
#include <algorithm>
#include <set>
#include <utility>
#include <QPointer>
@ -15,10 +17,85 @@ VtkSceneController::VtkSceneController(data::IDatasetRepository& dsRepo,
: QObject(parent), dsRepo_(dsRepo), sceneRepo_(sceneRepo), view_(view) {}
void VtkSceneController::setCheckedDatasets(const QStringList& dsIds) {
checkedDs_.clear();
checkedDs_.reserve(static_cast<std::size_t>(dsIds.size()));
for (const QString& id : dsIds) checkedDs_.push_back(id.toStdString());
rebuildInternal();
std::vector<std::string> newDs;
newDs.reserve(static_cast<std::size_t>(dsIds.size()));
for (const QString& id : dsIds) newDs.push_back(id.toStdString());
// 2D 俯视测线:保持全量重建(测线非按 ds 跟踪移除)。
if (mode_ == ViewMode::Map2D) {
checkedDs_ = std::move(newDs);
rebuildInternal();
return;
}
// 3D增量 diff —— 只处理新增/移除,不全量重建(底图、其余 ds、相机均不动
const std::set<std::string> oldSet(checkedDs_.begin(), checkedDs_.end());
const std::set<std::string> newSet(newDs.begin(), newDs.end());
const bool wasEmpty = checkedDs_.empty();
for (const auto& id : checkedDs_)
if (!newSet.count(id)) view_.removeDataset(id); // 移除:旧有新无 → 仅删该 ds 图元
checkedDs_ = std::move(newDs);
fitOnArrival_ = wasEmpty; // 仅从空开始时让到场数据自动取景;增量追加保持当前相机不跳
const unsigned long long gen = rebuildGeneration_; // 不自增:并发增量互不作废
for (const auto& id : checkedDs_)
if (!oldSet.count(id)) addDatasetAsync(id, gen); // 新增:新有旧无 → 异步取数增量入场
view_.renderIncremental(); // 立即反映移除 / 触发坐标轴重算
}
void VtkSceneController::addDatasetAsync(const std::string& dsId, unsigned long long gen) {
if (loadingDs_.count(dsId)) return; // 已在加载(重复勾选竞态)→ 不重复请求
QPointer<VtkSceneController> self(this);
if (showCurtain_) {
loadingDs_.insert(dsId);
sceneRepo_.loadSection(
dsId,
[self, gen, dsId](data::SectionData s) {
if (!self) return;
self->loadingDs_.erase(dsId);
if (gen != self->rebuildGeneration_ || !self->isChecked(dsId)) return; // 作废/已取消
self->view_.addCurtain(dsId, s.grid, s.scale);
self->onDatasetArrived();
},
[self, gen, dsId](const std::string& m) {
if (!self) return;
self->loadingDs_.erase(dsId);
if (gen != self->rebuildGeneration_) return;
emit self->loadFailed(QString::fromStdString(m));
});
}
if (showVoxel_) {
auto cached = volumeCache_.find(dsId);
if (cached != volumeCache_.end()) {
view_.addVolume(dsId, cached->second, colorScale(dsId));
onDatasetArrived();
} else {
sceneRepo_.loadVolume(
dsId,
[self, gen, dsId](data::VolumeGrid g) {
if (!self || gen != self->rebuildGeneration_ || !self->isChecked(dsId)) return;
auto it = self->volumeCache_.emplace(dsId, std::move(g)).first;
self->view_.addVolume(dsId, it->second, self->colorScale(dsId));
self->onDatasetArrived();
},
[self, gen](const std::string& m) {
if (!self || gen != self->rebuildGeneration_) return;
emit self->loadFailed(QString::fromStdString(m));
});
}
}
}
void VtkSceneController::onDatasetArrived() {
view_.renderIncremental();
if (fitOnArrival_) view_.fitView(); // 全量重建/首批数据 → 自动取景;增量追加保持相机
}
bool VtkSceneController::isChecked(const std::string& dsId) const {
return std::find(checkedDs_.begin(), checkedDs_.end(), dsId) != checkedDs_.end();
}
void VtkSceneController::setViewMode(ViewMode mode) {
@ -72,80 +149,42 @@ const geopro::core::ColorScale& VtkSceneController::colorScale(const std::string
}
void VtkSceneController::rebuildInternal() {
const unsigned long long gen = ++rebuildGeneration_;
const unsigned long long gen = ++rebuildGeneration_; // 自增:作废此前所有在途增量回调
const bool is2D = (mode_ == ViewMode::Map2D);
view_.clear();
view_.clear(); // 移除全部数据图元(保留底图)frame 重锚标志复位
loadingDs_.clear(); // 旧在途加载随之作废(回调按 gen 丢弃)
view_.setVerticalExaggeration(verticalExaggeration_);
// 坐标轴设置在 clear 后下发render 末尾据当前场景包围盒重建坐标轴 prop。
view_.setAxes(axesMode_, axesUnit_, kAxesFontSize);
fitOnArrival_ = true; // 全量重建:到场数据自动取景
inRebuild_ = true;
// 坏 dsIdloadGrid/loadColorScale 抛异常)= best-effort 跳过emit loadFailed 但不中断,
// 其余勾选数据集照常渲染(非 fail-fast
// 坏 dsIdloadGrid/loadColorScale 抛异常)= best-effort 跳过emit loadFailed 但不中断。
try {
if (is2D) {
for (const auto& dsId : checkedDs_) view_.addSurveyLine(grid(dsId));
} else {
// 回调用 QPointer<self> 守对象存活(控制器是 QObject+ gen 守数据新鲜:
// 将来 Api 实现在网络线程迟到回调时self 已析构则直接丢弃,不触 dangling。
// 回调用 QPointer<self> 守对象存活 + gen 守数据新鲜:迟到回调若已析构/作废则丢弃。
QPointer<VtkSceneController> self(this);
if (showTerrain_) {
sceneRepo_.loadTerrainPaths(
[self, gen](data::TerrainPaths p) {
if (!self || gen != self->rebuildGeneration_) return; // 已析构/迟到:丢弃
self->view_.addTerrain(std::move(p));
if (!self->inRebuild_) self->view_.render(false); // 同步路径由末尾统一 render
self->onDatasetArrived();
},
[self, gen](const std::string& m) {
if (!self || gen != self->rebuildGeneration_) return;
emit self->loadFailed(QString::fromStdString(m));
});
}
if (showCurtain_) {
for (const auto& dsId : checkedDs_) {
sceneRepo_.loadSection(
dsId,
[self, gen](data::SectionData s) {
if (!self || gen != self->rebuildGeneration_) return; // 已析构/迟到:丢弃
self->view_.addCurtain(s.grid, s.scale);
if (!self->inRebuild_) self->view_.render(false); // 同步路径由末尾统一 render
},
[self, gen](const std::string& m) {
if (!self || gen != self->rebuildGeneration_) return;
emit self->loadFailed(QString::fromStdString(m));
});
}
}
if (showVoxel_) {
for (const auto& dsId : checkedDs_) {
auto cached = volumeCache_.find(dsId);
if (cached != volumeCache_.end()) {
view_.addVolume(cached->second, colorScale(dsId));
continue;
}
sceneRepo_.loadVolume(
dsId,
[self, gen, dsId](data::VolumeGrid g) {
if (!self) return; // 控制器已析构:丢弃
if (gen != self->rebuildGeneration_) return; // 迟到回灌:丢弃
auto it = self->volumeCache_.emplace(dsId, std::move(g)).first;
self->view_.addVolume(it->second, self->colorScale(dsId));
if (!self->inRebuild_) self->view_.render(false); // 同步路径由末尾统一 render
},
[self, gen](const std::string& m) {
if (!self || gen != self->rebuildGeneration_) return;
emit self->loadFailed(QString::fromStdString(m));
});
}
}
for (const auto& dsId : checkedDs_) addDatasetAsync(dsId, gen);
}
} catch (const std::exception& e) {
emit loadFailed(QString::fromStdString(e.what()));
}
inRebuild_ = false;
view_.render(is2D);
view_.render(is2D); // 设背景/相机预设/坐标轴 + ResetCamera数据到场再由 onDatasetArrived 取景)
}
} // namespace geopro::controller

View File

@ -4,6 +4,7 @@
#include <QStringList>
#include <map>
#include <optional>
#include <set>
#include <string>
#include "I3dSceneView.hpp"
@ -54,6 +55,10 @@ signals:
private:
void rebuildInternal();
// 增量加入单个 ds帘面/体素,按图层开关);回调按 gen + 仍勾选 守护,落地后增量渲染。
void addDatasetAsync(const std::string& dsId, unsigned long long gen);
void onDatasetArrived(); // 单个 ds 落地后:增量渲染 + 首批数据自动取景
bool isChecked(const std::string& dsId) const;
data::IDatasetRepository& dsRepo_;
data::I3dSceneRepository& sceneRepo_;
@ -76,12 +81,13 @@ private:
std::map<std::string, geopro::core::ColorScale> colorScaleCache_;
std::map<std::string, data::VolumeGrid> volumeCache_;
// 异步回灌防护:每次 rebuild 自增,回调比对丢弃迟到结果。
// 异步回灌防护:每次全量 rebuild 自增,回调比对丢弃迟到结果。
unsigned long long rebuildGeneration_ = 0;
// rebuild 进行中标志同步回调LocalSample在 rebuild 内立即触发时跳过自身 render
// 由 rebuildInternal 末尾统一 render 覆盖(避免双重 ResetCamera/Render
// 真异步回调迟到时 inRebuild_ 已 false → 自行 render 追加。
bool inRebuild_ = false;
// 增量渲染状态:本批数据到场是否自动取景(全量重建/从空开始=true增量追加=false保持相机
bool fitOnArrival_ = true;
// 正在加载的 ds防重复勾选竞态重复请求全量重建时清空。
std::set<std::string> loadingDs_;
const geopro::core::Grid& grid(const std::string& dsId);
const geopro::core::ColorScale& colorScale(const std::string& dsId);

View File

@ -1,7 +1,9 @@
#include <gtest/gtest.h>
#include <functional>
#include <map>
#include <string>
#include <utility>
#include <vector>
#include "I3dSceneView.hpp"
@ -38,16 +40,34 @@ struct FakeView : I3dSceneView {
double lastZoomFactor = 0.0;
int fitCalls = 0;
// clear 模型化"移除所有图元"图元计数归零反映当前场景状态clears 累加。
// 按 ds 记录图元数,使 removeDataset 能按量回退(反映增量场景状态)。
std::map<std::string, std::pair<int, int>> perDs; // dsId → (curtains, volumes)
// clear 模型化"移除所有数据图元"计数归零clears 累加。
void clear() override {
++clears;
surveyLines = curtains = volumes = terrains = 0;
perDs.clear();
}
void setVerticalExaggeration(double v) override { ve = v; }
void addSurveyLine(const core::Grid&) override { ++surveyLines; }
void addCurtain(const core::Grid&, const core::ColorScale&) override { ++curtains; }
void addVolume(const data::VolumeGrid&, const core::ColorScale&) override { ++volumes; }
void addCurtain(const std::string& dsId, const core::Grid&, const core::ColorScale&) override {
++curtains;
++perDs[dsId].first;
}
void addVolume(const std::string& dsId, const data::VolumeGrid&,
const core::ColorScale&) override {
++volumes;
++perDs[dsId].second;
}
void addTerrain(const data::TerrainPaths&) override { ++terrains; }
void removeDataset(const std::string& dsId) override {
auto it = perDs.find(dsId);
if (it == perDs.end()) return;
curtains -= it->second.first;
volumes -= it->second.second;
perDs.erase(it);
}
void setAxes(AxesMode mode, AxesUnit unit, int fontSize) override {
++setAxesCalls;
lastAxesMode = mode; lastAxesUnit = unit; lastAxesFont = fontSize;
@ -56,6 +76,7 @@ struct FakeView : I3dSceneView {
void zoom(double factor) override { ++zoomCalls; lastZoomFactor = factor; }
void fitView() override { ++fitCalls; }
void render(bool is2D) override { ++renders; lastIs2D = is2D; }
void renderIncremental() override { ++renders; }
int props() const { return surveyLines + curtains + volumes + terrains; }
};
@ -180,19 +201,33 @@ TEST(VtkSceneController, View3DWithTerrainAddsTerrain) {
EXPECT_EQ(view.curtains, 1);
}
// 取消勾选 → clear 后无任何图元
TEST(VtkSceneController, UncheckAllClearsScene) {
// 取消勾选 → 增量移除该 ds 图元(不整场 clear3D 增量路径)
TEST(VtkSceneController, UncheckRemovesDatasetIncrementally) {
FakeDsRepo ds; FakeSceneRepo sc; FakeView view;
VtkSceneController c(ds, sc, view);
c.setViewMode(ViewMode::View3D);
c.setCheckedDatasets({"ds1"});
ASSERT_EQ(view.curtains, 1);
const int clearsAfterCheck = view.clears;
c.setCheckedDatasets({}); // 取消全部勾选
c.setCheckedDatasets({}); // 取消全部勾选 → 增量移除 ds1
EXPECT_EQ(view.curtains, 0);
EXPECT_EQ(view.volumes, 0);
// 最后一次重建仍调用 clear。
EXPECT_GE(view.clears, 2);
EXPECT_EQ(view.clears, clearsAfterCheck); // 增量取消不触发整场 clear
}
// 增量追加:已勾选 ds1 时再勾 ds2只新增 ds2不移除/重建 ds1。
TEST(VtkSceneController, IncrementalAddKeepsExisting) {
FakeDsRepo ds; FakeSceneRepo sc; FakeView view;
VtkSceneController c(ds, sc, view);
c.setViewMode(ViewMode::View3D);
c.setCheckedDatasets({"ds1"});
const int clearsAfterFirst = view.clears;
ASSERT_EQ(view.curtains, 1);
c.setCheckedDatasets({"ds1", "ds2"}); // 增量加 ds2
EXPECT_EQ(view.curtains, 2);
EXPECT_EQ(view.clears, clearsAfterFirst); // 不重建 → 无新 clear
}
// 纵向比例传到视图。