diff --git a/src/app/VtkSceneView.cpp b/src/app/VtkSceneView.cpp index 3988d7b..08c4512 100644 --- a/src/app/VtkSceneView.cpp +++ b/src/app/VtkSceneView.cpp @@ -6,6 +6,7 @@ #include #include +#include #include #include #include @@ -61,11 +62,24 @@ VtkSceneView::VtkSceneView(geopro::render::Scene& scene, vtkRenderWindow* render frame_(std::move(frame)), zRefElev_(zRefElev) {} +void VtkSceneView::removeProps(std::vector>& 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 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 diff --git a/src/app/VtkSceneView.hpp b/src/app/VtkSceneView.hpp index 6f6430b..b24c600 100644 --- a/src/app/VtkSceneView.hpp +++ b/src/app/VtkSceneView.hpp @@ -1,6 +1,9 @@ #pragma once #include +#include #include +#include +#include #include #include @@ -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 onVolumeChanged; + // frame 原点重锚(首个带经纬剖面到达)后回调,供底图等随之刷新到数据所在位置。 + std::function onFrameReanchored; + private: // 按当前坐标轴设置 + 场景包围盒重建坐标轴 prop(render 末尾调)。 void rebuildAxes(); + void removeProps(std::vector>& 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>> dsProps_; + std::vector> miscProps_; + std::string volumeOwnerDs_; // 当前 currentVolumeImage_ 归属的 ds(其被移除时置空切片源) }; } // namespace geopro::app diff --git a/src/controller/I3dSceneView.hpp b/src/controller/I3dSceneView.hpp index f745308..e705f78 100644 --- a/src/controller/I3dSceneView.hpp +++ b/src/controller/I3dSceneView.hpp @@ -1,4 +1,6 @@ #pragma once +#include + #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; // 3D:DEM 地形 + 影像纹理。 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: // 适配全览(P2):ResetCamera 并提交渲染。 virtual void fitView() = 0; - // 应用相机预设(2D 俯视 / 3D 自由)并提交渲染。 + // 应用相机预设(2D 俯视 / 3D 自由)并提交渲染(全量重建用,会 ResetCamera)。 virtual void render(bool is2D) = 0; + // 增量提交渲染:重建坐标轴并刷新,但不动相机(勾选/取消单个 ds 时视角不跳)。 + virtual void renderIncremental() = 0; }; } // namespace geopro::controller diff --git a/src/controller/VtkSceneController.cpp b/src/controller/VtkSceneController.cpp index cb9bfc1..e782536 100644 --- a/src/controller/VtkSceneController.cpp +++ b/src/controller/VtkSceneController.cpp @@ -1,5 +1,7 @@ #include "VtkSceneController.hpp" +#include +#include #include #include @@ -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(dsIds.size())); - for (const QString& id : dsIds) checkedDs_.push_back(id.toStdString()); - rebuildInternal(); + std::vector newDs; + newDs.reserve(static_cast(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 oldSet(checkedDs_.begin(), checkedDs_.end()); + const std::set 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 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; - // 坏 dsId(loadGrid/loadColorScale 抛异常)= best-effort 跳过:emit loadFailed 但不中断, - // 其余勾选数据集照常渲染(非 fail-fast)。 + // 坏 dsId(loadGrid/loadColorScale 抛异常)= best-effort 跳过:emit loadFailed 但不中断。 try { if (is2D) { for (const auto& dsId : checkedDs_) view_.addSurveyLine(grid(dsId)); } else { - // 回调用 QPointer 守对象存活(控制器是 QObject)+ gen 守数据新鲜: - // 将来 Api 实现在网络线程迟到回调时,self 已析构则直接丢弃,不触 dangling。 + // 回调用 QPointer 守对象存活 + gen 守数据新鲜:迟到回调若已析构/作废则丢弃。 QPointer 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 diff --git a/src/controller/VtkSceneController.hpp b/src/controller/VtkSceneController.hpp index 4378732..52ee0b7 100644 --- a/src/controller/VtkSceneController.hpp +++ b/src/controller/VtkSceneController.hpp @@ -4,6 +4,7 @@ #include #include #include +#include #include #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 colorScaleCache_; std::map 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 loadingDs_; const geopro::core::Grid& grid(const std::string& dsId); const geopro::core::ColorScale& colorScale(const std::string& dsId); diff --git a/tests/controller/test_vtk_scene_controller.cpp b/tests/controller/test_vtk_scene_controller.cpp index 13206dd..8e714e4 100644 --- a/tests/controller/test_vtk_scene_controller.cpp +++ b/tests/controller/test_vtk_scene_controller.cpp @@ -1,7 +1,9 @@ #include #include +#include #include +#include #include #include "I3dSceneView.hpp" @@ -38,16 +40,34 @@ struct FakeView : I3dSceneView { double lastZoomFactor = 0.0; int fitCalls = 0; - // clear 模型化"移除所有图元":图元计数归零(反映当前场景状态),clears 累加。 + // 按 ds 记录图元数,使 removeDataset 能按量回退(反映增量场景状态)。 + std::map> 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 图元(不整场 clear,3D 增量路径)。 +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 } // 纵向比例传到视图。