diff --git a/docs/superpowers/specs/2026-06-27-inversion-3d-volume-surfer-method-and-gaps.md b/docs/superpowers/specs/2026-06-27-inversion-3d-volume-surfer-method-and-gaps.md new file mode 100644 index 0000000..8141497 --- /dev/null +++ b/docs/superpowers/specs/2026-06-27-inversion-3d-volume-surfer-method-and-gaps.md @@ -0,0 +1,144 @@ +# 反演剖面三维体:客户目标方法(Surfer)对照与客户端差距 — 2026-06-27 + +> 来源:客户提供的演示视频 `ScreenShot/9f67e80cb823170bb9374d779ec4c0cb.mp4`(Golden Software Surfer,13min) +> 与参考成果图 `ScreenShot/Weixin Image_20260627080012_429_2117.png`。 +> 结论一句话:**客户用的就是"合并点云 + 3D 反距离加权(IDW) + 边界裁剪(Blanking) + 体绘制/等值面"。** +> 客户端插值内核已与之一致,差距只在「搜索半径 / 边界裁剪 / 等值面」三点。 + +--- + +## 0. 背景与一次认知纠偏 + +用户反馈:"选多个 dd_inversion_data(江西理工四条井字相交测线)做三维体,得到的是四个剖面各自左右 +拉伸的薄板,不连成完整体。" + +排查中曾推荐"逐深度层各向异性插值"——看了客户演示视频后**确认是过度设计**。客户没有逐层做, +就是**朴素的三维 IDW**(对合并后的整团散点云一次性 3D 网格化)。本文以视频实证为准。 + +--- + +## 1. 客户目标方法(Surfer 实测,逐帧为证) + +| 步骤 | 操作 | 视频帧(时间戳)证据 | +|---|---|---| +| 1. 合并点云 | 所有测线/剖面反演单元合并成**一个 XYZC 文件** `combine-e_m_m.xyz`,列= **X, Y, Elevation, Resistivity, Conductivity, Sensitivity** | t48.7:Grid Data 导入对话框,Data Type=**XYZC** | +| 2. 三维网格化 | Grid Data → 方法 **Inverse Distance to a Power(IDW,幂次)** → XYZC 直接生成 **3D 网格体**;注脚明示"does not extrapolate beyond the range of data" | t48.7:Gridding Method 选中 IDW;t97.5:Gridding 进度;t195:`.grd has been created` | +| 3. 边界裁剪 | 数字化**测区边界多边形**(Base vector / Polyline)→ **Blanking** 把体裁到测区真实足迹 | t292.5:Polygon 工具数字化边界 | +| 4. 三维渲染 | 3D View → 3D Grid Volume(`out-BAIHUA.vtk`):**Volume render**(Sliced / Slice count 500 / Tri-linear / Alpha blending / Opacity 80%) + **Isosurface**(阈值 isovalue) + **Image slice**(YZ/Z) | t585:Volume render 属性;t682:Isosurface(isovalue=1794.39);t633:Image slice(YZ) | + +要点: +- **真三维 IDW**,对合并点云一次成体(非逐层 2D)。 +- IDW **不外推到数据范围外**;测区足迹靠 **Blanking 多边形**裁出(参考图那个不规则边界即来源于此)。 +- 红色异常体 = **等值面抽取**(Isosurface 按阈值)。 +- 坐标为真实投影坐标 + 高程,可叠地形/影像底图。 + +--- + +## 2. 客户端现状(已实现部分) + +生产路径:`Api3dRepository::createVolume`(`src/data/api/Api3dRepository.cpp`) +→ 把所有选中 ds 的反演单元按测线真实几何配准合并成 `PointSet` +→ `buildVolume(pts, cellXY, cellZ, power, maxDist)`(`src/core/algo/VolumeBuilder.cpp`) +→ **三维 IDW**(`src/core/algo/IdwInterpolator.cpp`):`maxDist` 外置 NaN 留空。 + +即:**「合并点云 + 3D IDW」内核与 Surfer 一致**。参数见 `src/data/repo/VolumeBuildParams.hpp`: +`cellXY=1.0, cellZ=0.5, power=2.0, maxDist=4.0`。 + +渲染:`VoxelActor`(`src/render/actors/VoxelActor.hpp`)仅 GPU 体绘制(NaN→透明),**无等值面** +(`GridContourActor`/`ContourBands` 是 2D 网格等值线,非 3D 等值面)。地形/影像/坐标轴/电极点已有 +(`TerrainActor`/`TileBasemap`/`AxesActor`/`ElectrodeActor`)。 + +--- + +## 3. 差距与修复(共 3 点) + +> 不需改插值算法(内核已对);改的是搜索域、裁剪、与等值面。 + +### G1. 搜索半径 maxDist 太小 → "四块板" +`maxDist=4m` 远小于井字测线间距 → IDW 只填测线 ±4m 管套,线间留空 → 四块薄板。 +**修复**:把搜索半径放大到覆盖测区(或提供"覆盖全域"选项),对齐 Surfer 默认搜索域行为。 + +### G2. 缺边界裁剪(Blanking) → 单纯放大半径只会"变粗" +**这是用户观察到"调大 maxDist 只是让体看起来更粗"的真正原因**:没有足迹裁剪,放大半径会把体 +鼓满整个外接盒 → 粗大臃肿。Surfer 不粗,是因为 Blanking 把体裁到了测区真实多边形足迹。 +**修复**:加**足迹掩膜**—— +- 自动:散点平面**凸包 / alpha-shape**(或沿测线 buffer 并集); +- 或手动:支持用户数字化/导入边界多边形(对齐 Surfer Blanking)。 +掩膜外体素整列置空(NaN/透明)。 + +### G3. 缺 3D 等值面 → 出不来红色异常体 +`VoxelActor` 只有体绘制。 +**修复**:在体上加 **`vtkFlyingEdges3D` / `vtkContourFilter`** 抽等值面,阈值可调(对齐 Surfer Isosurface)。 + +--- + +## 4. 修复落地顺序 + +1. **G1+G2 一起做**(插值搜索域放大 + 足迹掩膜)→ 出满铺、裁到测区足迹的体。这是核心,先做。 +2. **G3 等值面**(阈值可调)→ 出红色异常体。参考图第二主角,紧接着做。 +3. 影像底图/坐标轴/电极点复用现有。 + +--- + +## 5. 必须先和客户对齐的预期(避免"又看起来不对") + +参考图是**密集测网**(顶面可见很多条测线点阵);江西项目只有**四条井字线**。 +- **形态可复刻**(满铺体 + 等值面 + 影像底图); +- 但**框内细节出不来**——参考图的细碎红异常源于密集采样,四条线之间只能给出平滑趋势, + 等值面会是几个光滑大团。要那种精度需加密测线。 + +--- + +## 6. 非目标 / 说明 + +- 不改 IDW 内核算法本身(已与 Surfer 一致),不引入逐层各向异性(客户未用)。 +- 各向异性搜索椭球可作为后续可选增强(Surfer 亦为可选项,非默认),本期不做。 +- Kriging 仍为占位(`VolumeBuildParams::Model::Kriging`,core 未实现),本期不依赖。 + +--- + +## 7. 续:白化方式之争 + 体绘制边界「梯田」(2026-06-28,branch `fix/3d-volume-blanking-mask`) + +> 本节记录 §1–6 之后这一轮的来龙去脉、技术取舍与权威佐证,供后续决策不再反复。 + +### 7.1 前因后果(时间线) + +客户原话:"**我们这个白化确实有问题,填色了,填了蓝色**"——无数据区被填成蓝色,而非 Surfer 那种透明白化。排查分三层、逐个修: + +1. **切片填蓝**:`vtkImagePlaneWidget` 会按【输入标量范围】(含哨兵)自动 window/level,把哨兵顶到 LUT 最低色格(蓝)且不透明。 + 修复:`SliceTool` 钉死 `SetWindowLevel([vmin,vmax])` + `ColorLutBuilder` 预留 0 号"白化槽"(全透明),哨兵( +- **GDAL 官方**:正确做法是把 masked NoData 的 "**weights of contributing source pixels are set to zero to ignore them**" / "will not be used in interpolation"。 +- **rasterio**:bilinear 重采样在 NoData 边界产生 invalid 值(已知 issue #1721)。 +- **Golden Software Surfer**(客户参照工具):NoData "**removed from the neighborhood**",不跨它插值。(定义 ) +- **凸包外 = 外推**:"**Extrapolated data is usually meaningless and misleading.**" + +**结论:二值 mask = 业界标准的"把 NoData 排除出插值"做法,是对的**;梯田只是 mask 在斜足迹边界上的网格离散观感(诚实、不误导)。**不应回退软消隐**(=让哨兵参与插值=以上权威明确反对的造假值做法)。 + +### 7.4 决策与待办(截至 2026-06-28,本分支未提交) + +- ✅ **保留二值 mask**(数据诚实/合规,符合 ESRI/GDAL/Surfer 标准)。 +- 梯田若要压平,走**不造假值**的路(二选一,**待用户/客户拍板**): + - (a) **细化 XY 网格**:`cellXY` 1m→0.5m,阶梯缩到亚像素;代价:体素×4、耗时 ~3.5s→~14s、内存×4。 + - (b) **接受梯田**:它诚实、且明显是足迹边界,不会被当成地质体。 +- 渲染侧本轮其它已落地修复(排查"分层/稠密"时做、确认非主因但保留):GPU 探测+CPU 回退(`setVolumeGpuSupported`)、细采样距离+`UseJittering`、`ScalarOpacityUnitDistance=对角/10`、去 `kMaxOpacity`(改由色阶「不透明度」单一控制、100%=实心)、移除工具条「透」滑块。 +- **附带缺陷(待修)**:`VoxelGenerateRequest::maxDist` 结构体默认 `4.0`(`src/data/dto/Vtk3dRequests.hpp:18`)与对话框 `kDefMaxDist=0.0`(`VolumeParamsDialog.cpp:34`)不一致——绕过对话框直建会拿到 4m → 退回"四块板/线间空隙"老问题,应统一为 0。 +- 抽稀空间哈希 `(ix*p1)^(iy*p2)^(iz*p3)`(`VolumeBuilder.cpp` ~146-151)为 XOR 非单射、有碰撞风险(与本症无关,但宜换 `(iz*ny+iy)*nx+ix` 线性键)。 diff --git a/src/app/CMakeLists.txt b/src/app/CMakeLists.txt index 77ab369..dd1434f 100644 --- a/src/app/CMakeLists.txt +++ b/src/app/CMakeLists.txt @@ -41,6 +41,7 @@ add_executable(geopro_desktop WIN32 panels/DescriptionPanel.cpp panels/QuillDelta.cpp panels/chart/RawDataChartView.cpp + panels/chart/ColorScaleProperties.cpp panels/chart/InversionFormDialog.cpp panels/chart/ScatterDataOps.cpp panels/chart/SaveAsDialog.cpp diff --git a/src/app/ColorGradientDialog.cpp b/src/app/ColorGradientDialog.cpp index b8d3125..0732411 100644 --- a/src/app/ColorGradientDialog.cpp +++ b/src/app/ColorGradientDialog.cpp @@ -205,11 +205,11 @@ ColorGradientDialog::ColorGradientDialog(const std::vector& init, double m // ── 整体透明度滑块(0~1, step 0.01) ─────────────────────────────────── { auto* opRow = new QHBoxLayout(); - opRow->addWidget(new QLabel(QStringLiteral("整体透明度:"))); + opRow->addWidget(new QLabel(QStringLiteral("不透明度:"))); opacitySlider_ = new QSlider(Qt::Horizontal, this); opacitySlider_->setRange(0, 100); opacitySlider_->setValue(static_cast(opacity_ * 100 + 0.5)); - opacityLabel_ = new QLabel(QString::number(opacity_, 'f', 2), this); + opacityLabel_ = new QLabel(QString::number(opacity_ * 100, 'f', 0), this); // 0~100 显示 opRow->addWidget(opacitySlider_, 1); opRow->addWidget(opacityLabel_); root->addLayout(opRow); @@ -247,7 +247,7 @@ ColorGradientDialog::ColorGradientDialog(const std::vector& init, double m [this](double) { onMinMaxChanged(); }); connect(opacitySlider_, &QSlider::valueChanged, this, [this](int v) { opacity_ = v / 100.0; - opacityLabel_->setText(QString::number(opacity_, 'f', 2)); + opacityLabel_->setText(QString::number(v)); // 0~100 显示(内部仍存 0~1) }); // 配色方案:内置预设打底,再异步拉取后端 .clr 列表(若有 api)。 diff --git a/src/app/ColorScaleConfigDialog.cpp b/src/app/ColorScaleConfigDialog.cpp index 8954879..1064ac2 100644 --- a/src/app/ColorScaleConfigDialog.cpp +++ b/src/app/ColorScaleConfigDialog.cpp @@ -112,6 +112,7 @@ ColorScaleConfigDialog::ColorScaleConfigDialog(const geopro::core::ColorScale& i resize(560, 420); // 用初始色阶的升序断点填模型;空色阶兜底成 vmin/vmax 两端蓝红。 + globalOpacity_ = init.globalOpacity(); // 回显真实整体透明度(两级第二级),不再硬编码 1 for (const auto& [value, color] : init.stops()) rows_.push_back({value, color}); if (rows_.empty()) { rows_.push_back({vmin_, geopro::core::Rgba{0, 0, 255, 255}}); @@ -354,15 +355,15 @@ void ColorScaleConfigDialog::onColorScheme() { std::vector seed; for (const auto& r : rows_) seed.push_back({(r.value - lo) / span, r.color}); - ColorGradientDialog dlg(seed, lo, hi, vmin_, vmax_, samples_, 1.0, tplRepo_, projectId_, this); + ColorGradientDialog dlg(seed, lo, hi, vmin_, vmax_, samples_, globalOpacity_, tplRepo_, projectId_, + this); if (dlg.exec() != QDialog::Accepted) return; const auto grad = dlg.stops(); if (grad.size() < 2) return; - const double opacity = dlg.opacity(); - const unsigned char alpha = static_cast(opacity * 255.0 + 0.5); + globalOpacity_ = dlg.opacity(); // 两级第二级:整体透明度单独存,不烘焙进每色 alpha - // 在新渐变上按各层级位置连续采样回填颜色(复刻 mapColors + addAlphaToColor 整体透明度)。 + // 在新渐变上按各层级位置连续采样回填颜色(复刻 mapColors,含每色自有 alpha)。 auto sampleGrad = [&](double pos) -> geopro::core::Rgba { if (pos <= grad.front().pos) return grad.front().color; if (pos >= grad.back().pos) return grad.back().color; @@ -372,11 +373,8 @@ void ColorScaleConfigDialog::onColorScheme() { const double t = (x1 > x0) ? (pos - x0) / (x1 - x0) : 0.0; return lerp(grad[i].color, grad[i + 1].color, t); }; - for (auto& r : rows_) { - geopro::core::Rgba c = sampleGrad((r.value - lo) / span); - if (opacity < 1.0) c.a = alpha; // 整体透明度覆盖 alpha - r.color = c; - } + // 只回填颜色(含每色自有 alpha),整体透明度单独存于 globalOpacity_、渲染时才相乘(两级)。 + for (auto& r : rows_) r.color = sampleGrad((r.value - lo) / span); rebuildTable(); } @@ -559,6 +557,7 @@ void ColorScaleConfigDialog::onOpen() { geopro::core::ColorScale ColorScaleConfigDialog::colorScale() const { geopro::core::ColorScale cs; for (const auto& row : rows_) cs.addStop(row.value, row.color); // 内部按 value 升序 + cs.setGlobalOpacity(globalOpacity_); // 整体透明度独立带出(渲染时与每色 alpha 相乘) return cs; } diff --git a/src/app/ColorScaleConfigDialog.hpp b/src/app/ColorScaleConfigDialog.hpp index f8b6aba..52a177d 100644 --- a/src/app/ColorScaleConfigDialog.hpp +++ b/src/app/ColorScaleConfigDialog.hpp @@ -44,6 +44,11 @@ public: // 线形/标注配置(线形⚙ 编辑后;2D 消费,3D 忽略)。 ContourLineConfig lineConfig() const { return lineCfg_; } + // 层级方案透传字段(复刻原版 properties:保存色阶时一并写回,避免覆盖清空 web 设过的值)。 + QString lvlSchemeType() const { return lvlSchemeType_; } + int logLinesCount() const { return logLinesCount_; } + int equalAreaLayerCount() const { return equalAreaLayerCount_; } + private: struct Row { double value; @@ -70,6 +75,7 @@ private: QTableWidget* table_ = nullptr; std::vector rows_; // 始终按 value 升序维护 + double globalOpacity_ = 1.0; // 整体透明度(两级第二级,独立存储,不烘焙进 rows_ 的 alpha) double vmin_ = 0.0; double vmax_ = 0.0; std::vector samples_; // 数据原始标量(等积分层 + 直方图) diff --git a/src/app/VtkSceneView.cpp b/src/app/VtkSceneView.cpp index e27753c..b9e10cc 100644 --- a/src/app/VtkSceneView.cpp +++ b/src/app/VtkSceneView.cpp @@ -16,6 +16,8 @@ #include #include #include +#include +#include #include #include #include @@ -35,20 +37,6 @@ namespace geopro::app { namespace { -// 运行时改某体的最大不透明度:把其不透明度传递函数「最高 x 的点」(=vmax/qmax 处的最大不透明度点) -// 的不透明度值设为 maxOpacity。不重建体、不动颜色与留空透明点,实时生效。 -void applyVolumeOpacity(vtkVolume* v, double maxOpacity) { - if (!v || !v->GetProperty()) return; - vtkPiecewiseFunction* op = v->GetProperty()->GetScalarOpacity(); - if (!op) return; - const int n = op->GetSize(); - if (n <= 0) return; - double node[4]; // {x, y(opacity), midpoint, sharpness} - op->GetNodeValue(n - 1, node); - node[1] = maxOpacity; - op->SetNodeValue(n - 1, node); -} - // 控制器层枚举 → render 层枚举(保持控制器不依赖 render)。 geopro::render::AxesMode toRenderMode(geopro::controller::AxesMode m) { switch (m) { @@ -140,11 +128,9 @@ void VtkSceneView::clear() { void VtkSceneView::setVerticalExaggeration(double ve) { verticalExaggeration_ = ve; } -void VtkSceneView::setVolumeOpacity(double maxOpacity) { - volumeOpacity_ = std::clamp(maxOpacity, 0.0, 1.0); // 记为后续新体默认 - for (auto& kv : volumes_) // 实时更新所有已渲染体(不重建) - applyVolumeOpacity(kv.second.volume, volumeOpacity_); - if (renderWindow_) renderWindow_->Render(); +void VtkSceneView::setVolumeOpacity(double /*maxOpacity*/) { + // 已退役:体不透明度统一由【色阶「不透明度」】控制(每单位 = 单色alpha × 色阶不透明度,100%=实心)。 + // 旧工具条「透明度」滑块移除;保留空实现仅为满足接口(无调用方)。 } void VtkSceneView::addSurveyLine(const geopro::core::Grid& grid) { @@ -186,6 +172,29 @@ void VtkSceneView::addCurtain(const std::string& dsId, const geopro::core::Grid& void VtkSceneView::addVolume(const std::string& dsId, const geopro::data::VolumeGrid& vol, const geopro::core::ColorScale& cs) { + // 首次建体时一次性探测 GPU 体绘制支持(此刻 widget 已显示、GL 上下文就绪):不支持则全局回退 + // SmartVolumeMapper(CPU),避免无独显/软件 GL/远程桌面上整个体渲不出(空值仍靠传函透明)。 + static bool gpuProbed = false; + if (!gpuProbed && renderWindow_) { + gpuProbed = true; + // 关键:addVolume 在普通 Qt 槽里跑,GL 上下文未必 current → 先 MakeCurrent,否则 IsRenderSupported + // 误判为不支持、把有独显的机器错误回退到 CPU(体变稠密/分层)。再给真实传函属性供其判定。 + renderWindow_->MakeCurrent(); + vtkNew probe; + vtkNew prop; + vtkNew ctf; + ctf->AddRGBPoint(0.0, 1, 1, 1); + ctf->AddRGBPoint(1.0, 1, 1, 1); + vtkNew otf; + otf->AddPoint(0.0, 0.0); + otf->AddPoint(1.0, 1.0); + prop->SetColor(ctf); + prop->SetScalarOpacity(otf); + const bool ok = probe->IsRenderSupported(renderWindow_, prop) != 0; + geopro::render::setVolumeGpuSupported(ok); + qInfo().noquote() << "[volrender] GPU volume ray cast supported=" << ok + << (ok ? "(GPU+mask 干净白化)" : "(回退 CPU SmartVolumeMapper,边缘有细渗色)"); + } // 纵向夸张烤进 image 的 z 原点/间距(与帘面 SetScale 同倍,保证纵向一致)。 // 用暴露 image 的 buildVoxel 重载:保留 currentVolumeImage_ 供 P3 切片附着(几何含 VE)。 vtkSmartPointer image; @@ -198,7 +207,6 @@ void VtkSceneView::addVolume(const std::string& dsId, const geopro::data::Volume // picker 命中体、worldPoint 落体内 → nearestSlice 按平面距离选错切片(用户 ④ 串选)。 volume->PickableOff(); volume->SetVisibility(analysisMode2D_ ? 0 : 1); // 体=3D内容:二维分析下隐藏 - applyVolumeOpacity(volume, volumeOpacity_); // 套用当前透明度(工具条调过则新体跟随) scene_.addViewProp(volume); dsProps_[dsId].push_back(volume); currentVolumeImage_ = image; @@ -221,6 +229,21 @@ void VtkSceneView::addVolume(const std::string& dsId, const geopro::data::Volume } } +bool VtkSceneView::updateVolumeColorInPlace(const std::string& dsId, + const geopro::core::ColorScale& cs) { + auto it = volumes_.find(dsId); + if (it == volumes_.end() || !it->second.volume) return false; // 未渲染 → 调用方回退 remove+add + // 仅换传函(image 不变)→ 切片基底保持有效、不被关闭。等值面随阈值色变化较小,暂不重抽。 + geopro::render::updateVolumeColors(it->second.volume, cs, it->second.vmin, it->second.vmax); + it->second.cs = cs; + currentColorScale_ = cs; + // onVolumeChanged → InteractionManager.setVolumeImage(同 image, 新 cs):检测 image 未变 → 不关切片, + // 仅更新体色阶并让该体下未保存切片跟随改色(见 InteractionManager::setVolumeImage)。 + if (onVolumeChanged) onVolumeChanged(); + if (renderWindow_) renderWindow_->Render(); + return true; +} + void VtkSceneView::addMapLine(const std::string& dsId, const geopro::data::MapLine& line, double worldZ) { // 2D 足迹:经共享 frame 投影到世界 XY、Z=worldZ。按 dsId 跟踪(与帘面同 dsProps_ → removeDataset 复用)。 diff --git a/src/app/VtkSceneView.hpp b/src/app/VtkSceneView.hpp index b7e67cf..6b1c8a9 100644 --- a/src/app/VtkSceneView.hpp +++ b/src/app/VtkSceneView.hpp @@ -42,6 +42,8 @@ public: const geopro::core::ColorScale& cs) override; void addVolume(const std::string& dsId, const geopro::data::VolumeGrid& vol, const geopro::core::ColorScale& cs) override; + bool updateVolumeColorInPlace(const std::string& dsId, + const geopro::core::ColorScale& cs) override; void addMapLine(const std::string& dsId, const geopro::data::MapLine& line, double worldZ) override; void addTerrain(const geopro::data::TerrainPaths& paths) override; @@ -129,7 +131,6 @@ private: std::shared_ptr frame_; double zRefElev_; double verticalExaggeration_ = 1.0; - double volumeOpacity_ = 0.30; // 三维体体绘制最大不透明度(默认 0.30,工具条可调);新体建好即套用 // 是否已按真实剖面 lat/lon 重锚 frame 原点(每次 clear 重置):默认原点取自样本、可能离真实数据 // 很远→局部坐标巨大、轴刻度无意义;首个带经纬剖面到达时重锚到其中心,同选择内多剖面共用配准。 bool frameAnchoredToData_ = false; diff --git a/src/app/VtkViewToolbar.cpp b/src/app/VtkViewToolbar.cpp index 0796058..95c766b 100644 --- a/src/app/VtkViewToolbar.cpp +++ b/src/app/VtkViewToolbar.cpp @@ -70,10 +70,7 @@ VtkViewToolbar::VtkViewToolbar(QWidget* parent) : QWidget(parent) { viewDirButtons_.push_back(b); // 二维分析下禁用(会改朝向、破坏近俯视锁定) } sep(); - // ── 段3:透明度(缩放段顶部,放大上面)+ 缩放 / 复位 ── - opacityBtn_ = textBtn(QStringLiteral("透")); - opacityBtn_->setToolTip(QStringLiteral("三维体透明度")); - connect(opacityBtn_, &QToolButton::clicked, this, &VtkViewToolbar::showOpacityPopup); + // ── 段3:缩放 / 复位 ──(三维体不透明度已移交色阶「不透明度」,旧「透」滑块退役移除) connect(iconBtn(Glyph::Plus, QStringLiteral("放大")), &QToolButton::clicked, this, &VtkViewToolbar::zoomInRequested); connect(iconBtn(Glyph::Minus, QStringLiteral("缩小")), &QToolButton::clicked, this, @@ -90,37 +87,6 @@ VtkViewToolbar::VtkViewToolbar(QWidget* parent) : QWidget(parent) { "QToolButton:hover{background:{{bg/hover}};color:{{accent/primary}};}")); setFixedWidth(44); adjustSize(); - - // 透明度弹出面板(Qt::Popup → 点击外部自动关闭):横向滑块 0~100(%),默认 30(=0.30)。 - opacityPopup_ = new QWidget(this, Qt::Popup); - opacityPopup_->setAttribute(Qt::WA_StyledBackground, true); - applyTokenizedStyleSheet( - opacityPopup_, - QStringLiteral("QWidget{background:{{bg/panel-subtle}};border:1px solid {{border/default}};" - "border-radius:8px;}QLabel{border:none;color:{{text/primary}};}")); - auto* pl = new QVBoxLayout(opacityPopup_); - pl->setContentsMargins(space::kMd, space::kSm, space::kMd, space::kSm); - pl->setSpacing(space::kSm); - opacityLabel_ = new QLabel(QStringLiteral("透明度 30%"), opacityPopup_); - opacitySlider_ = new QSlider(Qt::Horizontal, opacityPopup_); - opacitySlider_->setRange(0, 100); - opacitySlider_->setValue(30); // 默认 0.30,与体绘制默认一致 - opacitySlider_->setFixedWidth(scaledPx(160)); - pl->addWidget(opacityLabel_); - pl->addWidget(opacitySlider_); - connect(opacitySlider_, &QSlider::valueChanged, this, [this](int v) { - opacityLabel_->setText(QStringLiteral("透明度 %1%").arg(v)); - emit opacityChanged(v / 100.0); // 实时下发 - }); - opacityPopup_->adjustSize(); -} - -void VtkViewToolbar::showOpacityPopup() { - if (!opacityPopup_ || !opacityBtn_) return; - // 弹在「透」按钮右侧,与按钮顶对齐(全局坐标,Qt::Popup 顶层窗口)。 - opacityPopup_->move(opacityBtn_->mapToGlobal(QPoint(opacityBtn_->width() + 6, 0))); - opacityPopup_->show(); - opacityPopup_->raise(); } void VtkViewToolbar::setAnalysisMode2D(bool is2D) { diff --git a/src/app/VtkViewToolbar.hpp b/src/app/VtkViewToolbar.hpp index 493ae21..875dc77 100644 --- a/src/app/VtkViewToolbar.hpp +++ b/src/app/VtkViewToolbar.hpp @@ -27,16 +27,9 @@ signals: void zoomInRequested(); void zoomOutRequested(); void fitRequested(); // 复位=适配 - void opacityChanged(double maxOpacity); // 三维体透明度滑块(0~1,实时) private: - void showOpacityPopup(); // 在透明度按钮旁弹出滑块面板 - std::vector viewDirButtons_; // 6 向快捷视图按钮:二维分析下禁用 - QToolButton* opacityBtn_ = nullptr; // 「透」透明度按钮(缩放段顶部) - QWidget* opacityPopup_ = nullptr; // 弹出滑块面板(Qt::Popup,点外即关) - QSlider* opacitySlider_ = nullptr; // 0~100(% → 0~1) - QLabel* opacityLabel_ = nullptr; // 「透明度 N%」读数 }; } // namespace geopro::app diff --git a/src/app/main.cpp b/src/app/main.cpp index bb7fdeb..52ece32 100644 --- a/src/app/main.cpp +++ b/src/app/main.cpp @@ -117,6 +117,7 @@ #include "ImportDatasetDialog.hpp" #include "panels/web/ProjectWebView.hpp" #include "WorkbenchNavController.hpp" +#include "DatasetViewState.hpp" #include "VtkSceneController.hpp" #include "VtkSceneView.hpp" #include "api/NavRequest.hpp" @@ -301,6 +302,10 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re auto* sceneCtrl = new geopro::controller::VtkSceneController(repo, *scene3dRepo, *sceneView, vtkWidget); sceneCtrl->setVerticalExaggeration(kVerticalExaggeration); + // 跨视图色阶真源(统一同步机制):2D 详情/3D 帘面/体 共用一份按 dsId 的色阶;编辑→真源→各视图跟随。 + // parent=vtkWidget → 随窗口销毁清理;须早于详情面板创建以便注入。 + auto* viewState = new geopro::controller::DatasetViewState(vtkWidget); + sceneCtrl->setViewState(viewState); // ── P3 切片交互编排(InteractionManager)───────────────────────────────── // interactor 由 QVTK 在 setRenderWindow 后提供(renderWindow->GetInteractor())。 @@ -525,9 +530,14 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re } for (const std::string& id : *checkedSliceIds) { geopro::data::I3dSceneRepository::SliceSpec sp; - if (scene3dRepo->sliceSpec(id, sp) && interactionMgr->isVolumeRendered(sp.volumeDsId)) + if (scene3dRepo->sliceSpec(id, sp) && interactionMgr->isVolumeRendered(sp.volumeDsId)) { interactionMgr->showSavedSlice(id, sp.axis, sp.origin, sp.point1, sp.point2, sp.volumeDsId); + // 已保存切片用自己的色阶(颜色+不透明度);无则跟随三维体(兜底,showSavedSlice 已用体色阶)。 + geopro::core::ColorScale scs; + if (scene3dRepo->sliceColorScale(id, scs)) + interactionMgr->setSliceColorScaleByDsId(id, scs); + } } }; @@ -623,6 +633,15 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re QAction* aImg = expMenu->addAction(QStringLiteral("图片")); QAction* aDat = expMenu->addAction(QStringLiteral("dat")); menu.addSeparator(); + // 「不透明度」放入视觉分组(正视图上方),仅对未保存切片显示(已保存切片改不透明度走列表右键 + // 「色阶」)。颜色映射始终跟随三维体;这里只设总不透明度。 + QAction *aOp100 = nullptr, *aOpPlus50 = nullptr, *aOpFollow = nullptr; + if (interactionMgr->selectedSliceDsId().empty()) { + QMenu* opMenu = menu.addMenu(QStringLiteral("不透明度")); + aOp100 = opMenu->addAction(QStringLiteral("100%")); + aOpPlus50 = opMenu->addAction(QStringLiteral("三维体+50%")); + aOpFollow = opMenu->addAction(QStringLiteral("跟随三维体")); + } QAction* aFace = menu.addAction(QStringLiteral("正视图")); QAction* aFlip = menu.addAction(QStringLiteral("视图翻转")); QAction* aClose = menu.addAction(QStringLiteral("关闭")); @@ -632,6 +651,23 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re if (chosen == aFace) { interactionMgr->faceSelected(); return; } if (chosen == aFlip) { interactionMgr->flipView(); return; } if (chosen == aClose) { interactionMgr->closeSelected(); return; } // →onSliceClosed→取消列表勾选 + if (aOp100 && chosen == aOp100) { + interactionMgr->setSelectedSliceOpacity(geopro::render::interact::SliceOpacityMode::Full); + vtkWidget->update(); + return; + } + if (aOpPlus50 && chosen == aOpPlus50) { + interactionMgr->setSelectedSliceOpacity( + geopro::render::interact::SliceOpacityMode::VolumePlus50); + vtkWidget->update(); + return; + } + if (aOpFollow && chosen == aOpFollow) { + interactionMgr->setSelectedSliceOpacity( + geopro::render::interact::SliceOpacityMode::FollowVolume); + vtkWidget->update(); + return; + } if (chosen == aAnoPoint || chosen == aAnoLine || chosen == aAnoFace) { // 形态(1点/2线/3面):同时决定绘制工具 mode、a.markType、对话框查平台类型的 remarkSourceType。 // core::AnomalyMarkType 与 remarkSourceType 同值(Point=1/Polyline=2/Polygon=3),用一个 shape 贯通。 @@ -783,10 +819,14 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re QLineEdit::Normal, QStringLiteral("切片"), &ok); if (!ok) return; + // 保存时快照切片自己的色阶对象:颜色继承当时三维体 + 不透明度取当前具体值(并入 globalOpacity)。 + const geopro::core::ColorScale sliceCs = + interactionMgr->selectedSliceColorScaleSnapshot(); scene3dRepo->createSlice( spec, name.isEmpty() ? std::string("切片") : name.toStdString(), - [interactionMgr, refreshAnalysis, drawer](std::string newId) { + [interactionMgr, refreshAnalysis, drawer, scene3dRepo, sliceCs](std::string newId) { interactionMgr->tagSelectedSlice(newId); // 链接当前切片 → 新数据集(不重绘) + scene3dRepo->setSliceColorScale(newId, sliceCs); // 存切片独立色阶(mock) refreshAnalysis(); // 新行进列表 // 新切片自动勾选 → 列表打勾 + 保持渲染(refreshAnalysis 已重建列表,故在其后勾选)。 if (auto* sec = drawer->analysisTab()->section("voxel")) @@ -838,13 +878,7 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re &geopro::controller::VtkSceneController::zoomOut); QObject::connect(viewToolbar, &geopro::app::VtkViewToolbar::fitRequested, sceneCtrl, &geopro::controller::VtkSceneController::fit); - // 透明度滑块 → 运行时调三维体不透明度(实时)。vtkWidget->update() 保证离屏渲染呈现到屏 - // (滑块在弹出面板上,vtkWidget 自身无 paint 事件,需显式请求重绘,同 volumeRendered 修复)。 - QObject::connect(viewToolbar, &geopro::app::VtkViewToolbar::opacityChanged, vtkWidget, - [sceneCtrl, vtkWidget](double op) { - sceneCtrl->setVolumeOpacity(op); - vtkWidget->update(); - }); + // (三维体不透明度已移交色阶「不透明度」;旧工具条「透」滑块退役。) // 设置(⚙)→ 工具条右侧 toggle 抽屉面板(非模态弹窗)。 QObject::connect(viewToolbar, &geopro::app::VtkViewToolbar::axesSettingsRequested, &window, [axesPanel, viewToolbar]() { @@ -1102,8 +1136,30 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re // 色阶(三维体/切片):复刻原版「色阶配置」对话框,确定后体素 + 其切片随新色阶重渲染。 // 仅对当前已渲染的三维体生效(切片色阶继承体色阶,经 InteractionManager 重建)。 QObject::connect(analysisTab, &geopro::app::CategoryAnalysisTab::colorScaleRequested, &window, - [&window, &colorTplRepo, &nav, sceneCtrl, sceneView](const QString& qid) { + [&window, &colorTplRepo, &nav, sceneCtrl, sceneView, scene3dRepo, + interactionMgr](const QString& qid) { const std::string dsId = qid.toStdString(); + // 已保存切片(dd_slice)→ 编辑切片自己的色阶(颜色+不透明度),不走三维体路径。 + if (scene3dRepo->isSliceDataset(dsId)) { + geopro::core::ColorScale scs; + if (!scene3dRepo->sliceColorScale(dsId, scs)) { + QMessageBox::information(&window, QStringLiteral("色阶"), + QStringLiteral("该切片暂无独立色阶。")); + return; + } + const auto stops = scs.stopValues(); + const double vmin = stops.empty() ? 0.0 : stops.front(); + const double vmax = stops.empty() ? 1.0 : stops.back(); + geopro::app::ColorScaleConfigDialog dlg(scs, vmin, vmax, {}, {}, + &colorTplRepo, + nav.currentProjectId(), QString(), + &window); + if (dlg.exec() != QDialog::Accepted) return; + const auto newCs = dlg.colorScale(); + scene3dRepo->setSliceColorScale(dsId, newCs); // 存切片独立色阶(mock) + interactionMgr->setSliceColorScaleByDsId(dsId, newCs); // 若在渲染则即时改色 + return; + } // 多体并发:编辑"该体"(任一已渲染体,不限当前体)的色阶。 const auto* vol = sceneView->volume(dsId); if (!vol) { @@ -1333,6 +1389,7 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re // 单击数据集 → 聚焦已开页;双击 → 新建/聚焦页(真实反演剖面/散点/异常/色阶)。 auto* detailPanel = new geopro::app::DatasetDetailPanel(); // 注入色阶模板仓储 + 当前 projectId 取值回调(网格剖面「色阶配置」编辑器的另存/打开/新建色阶)。 + detailPanel->setViewState(viewState); // 跨视图色阶真源(2D↔3D 同步) detailPanel->setColorTemplateRepo(&colorTplRepo, [&nav] { return nav.currentProjectId(); }); // 注入反演命令仓储(measurement 反演运算/生成视电阻率)。projectId 取值仍由页内 projectIdGetter 提供。 detailPanel->setCommandRepo(&cmdRepo); diff --git a/src/app/panels/DatasetDetailPage.cpp b/src/app/panels/DatasetDetailPage.cpp index 0cc7f9b..5f953bc 100644 --- a/src/app/panels/DatasetDetailPage.cpp +++ b/src/app/panels/DatasetDetailPage.cpp @@ -51,7 +51,8 @@ void DatasetDetailPage::build(const QString& dsId, const QString& ddCode, const // dsIdGetter 用本页 dsId_(此处已赋值),随项目/数据集稳定。 auto view = makeDetailView(spec.kind, this, colorTplRepo_, projectIdGetter_, cmdRepo_, [this] { return dsId_; }, - [this] { return tmObjectId_; }); // 抛出由调用栈兜底(GuardedApplication) + [this] { return tmObjectId_; }, + viewState_); // 抛出由调用栈兜底(GuardedApplication) IDetailView* raw = view.release(); // QWidget 由 this/QwtPlot 父子树接管生命周期 views_[i] = raw; // lazy 页签:建覆盖该视图的加载遮罩(父为视图 widget,随其尺寸覆盖图区)。 diff --git a/src/app/panels/DatasetDetailPage.hpp b/src/app/panels/DatasetDetailPage.hpp index a80564e..b158e6d 100644 --- a/src/app/panels/DatasetDetailPage.hpp +++ b/src/app/panels/DatasetDetailPage.hpp @@ -12,6 +12,10 @@ class IColorTemplateRepository; class IDatasetCommandRepository; } +namespace geopro::controller { +class DatasetViewState; // 跨视图色阶真源(统一同步) +} + namespace geopro::app { class IDetailView; @@ -35,6 +39,9 @@ public: // 所属 TM 对象 id(=白化 structParentId)注入(须在 build 前设置 → tmObjectIdGetter 透传给视图)。 void setTmObjectId(const QString& tmObjectId) { tmObjectId_ = tmObjectId; } + // 跨视图色阶真源注入(须在 build 前设置 → 透传给网格视图,实现 2D↔3D 色阶同步)。 + void setViewState(geopro::controller::DatasetViewState* state) { viewState_ = state; } + // 按页签集构建页签(首次打开调一次)。dsId/ddCode/dsName 用于 tabNeeded。 void build(const QString& dsId, const QString& ddCode, const QString& dsName, const std::vector& tabs); @@ -75,6 +82,8 @@ private: // 反演命令仓储注入(透传给 makeDetailView → measurement 散点视图反演按钮)。 geopro::data::IDatasetCommandRepository* cmdRepo_ = nullptr; + + geopro::controller::DatasetViewState* viewState_ = nullptr; // 跨视图色阶真源(透传给网格视图) }; } // namespace geopro::app diff --git a/src/app/panels/DatasetDetailPanel.cpp b/src/app/panels/DatasetDetailPanel.cpp index 8eb2009..df37e1d 100644 --- a/src/app/panels/DatasetDetailPanel.cpp +++ b/src/app/panels/DatasetDetailPanel.cpp @@ -15,6 +15,10 @@ void DatasetDetailPanel::setCommandRepo(geopro::data::IDatasetCommandRepository* cmdRepo_ = repo; } +void DatasetDetailPanel::setViewState(geopro::controller::DatasetViewState* state) { + viewState_ = state; +} + DatasetDetailPanel::DatasetDetailPanel(QWidget* parent) : QTabWidget(parent) { setTabsClosable(true); connect(this, &QTabWidget::tabCloseRequested, this, [this](int i) { widget(i)->deleteLater(); }); @@ -40,6 +44,7 @@ void DatasetDetailPanel::onDatasetOpened(const QString& dsId, const QString& ddC // 注入须在 build 前(build 内造视图时即透传给工厂)。 p->setColorTemplateRepo(colorTplRepo_, projectIdGetter_); p->setCommandRepo(cmdRepo_); + p->setViewState(viewState_); // 跨视图色阶真源(build 前设置 → 透传给网格视图) p->setTmObjectId(tmObjectId); // 白化 structParentId(build 前设置 → 透传给视图) p->build(dsId, ddCode, dsName, tabs); // ddCode 透传 → 页内 tabNeeded 携带 const QString title = dsName.isEmpty() ? dsId : dsName; // 页签标题用数据名(空则回退 id) diff --git a/src/app/panels/DatasetDetailPanel.hpp b/src/app/panels/DatasetDetailPanel.hpp index 8babdd4..3e89e5a 100644 --- a/src/app/panels/DatasetDetailPanel.hpp +++ b/src/app/panels/DatasetDetailPanel.hpp @@ -9,6 +9,9 @@ namespace geopro::data { class IColorTemplateRepository; class IDatasetCommandRepository; } +namespace geopro::controller { +class DatasetViewState; // 跨视图色阶真源(统一同步) +} namespace geopro::app { class DatasetDetailPage; @@ -25,6 +28,9 @@ public: // 反演命令仓储:透传给每个新建的详情页(measurement 反演运算/生成视电阻率用)。 void setCommandRepo(geopro::data::IDatasetCommandRepository* repo); + // 跨视图色阶真源:透传给每个新建的详情页 → 网格视图(2D↔3D 色阶同步)。 + void setViewState(geopro::controller::DatasetViewState* state); + // 数据集打开:find-or-create 页 → build(tabs) → 加/抬该面板页签。 // tmObjectId:所属 TM 对象 id(白化 structParentId),build 前交给页 → 视图。 void onDatasetOpened(const QString& dsId, const QString& ddCode, const QString& dsName, @@ -51,5 +57,7 @@ private: // 反演命令仓储注入(新页 build 前 setCommandRepo 透传)。 geopro::data::IDatasetCommandRepository* cmdRepo_ = nullptr; + + geopro::controller::DatasetViewState* viewState_ = nullptr; // 跨视图色阶真源(透传给详情页) }; } // namespace geopro::app diff --git a/src/app/panels/chart/ColorMapService.cpp b/src/app/panels/chart/ColorMapService.cpp index e316468..0c51c75 100644 --- a/src/app/panels/chart/ColorMapService.cpp +++ b/src/app/panels/chart/ColorMapService.cpp @@ -11,6 +11,11 @@ inline double clamp01(double v) { inline unsigned char lerpByte(unsigned char a, unsigned char b, double t) { return static_cast(a + (b - a) * t + 0.5); } +// 两级透明度:每色 alpha × 整体透明度(渲染时相乘,不烘焙)。 +inline core::Rgba applyGlobalAlpha(core::Rgba c, double g) { + c.a = static_cast(c.a * g + 0.5); + return c; +} } // namespace ColorMapService::ColorMapService(const core::ColorScale& scale) @@ -47,17 +52,18 @@ double ColorMapService::normalized(double v) const { } core::Rgba ColorMapService::colorAtContinuous(double v) const { - if (normStops_.empty()) return core::Rgba{0, 0, 0, 255}; - if (normStops_.size() == 1) return normStops_.front().color; + const double g = scale_.globalOpacity(); // 两级第二级:整体透明度 + if (normStops_.empty()) return applyGlobalAlpha(core::Rgba{0, 0, 0, 255}, g); + if (normStops_.size() == 1) return applyGlobalAlpha(normStops_.front().color, g); double t = normalized(v); // 非有限值(NaN/Inf,可能来自降级后端的脏数据或退化数据范围):回退首断点色, // 避免下方 upper_bound 用 NaN 比较返回 end() 后解引用越界(崩溃)。 - if (!std::isfinite(t)) return normStops_.front().color; + if (!std::isfinite(t)) return applyGlobalAlpha(normStops_.front().color, g); // 找到 t 落在哪两个 normStop 之间 - if (t <= normStops_.front().pos) return normStops_.front().color; - if (t >= normStops_.back().pos) return normStops_.back().color; + if (t <= normStops_.front().pos) return applyGlobalAlpha(normStops_.front().color, g); + if (t >= normStops_.back().pos) return applyGlobalAlpha(normStops_.back().color, g); // 二分查找第一个 pos > t auto it = std::upper_bound(normStops_.begin(), normStops_.end(), t, @@ -68,16 +74,16 @@ core::Rgba ColorMapService::colorAtContinuous(double v) const { double segLen = hi.pos - lo.pos; double frac = (segLen > 0.0) ? (t - lo.pos) / segLen : 0.0; - return core::Rgba{ + return applyGlobalAlpha(core::Rgba{ lerpByte(lo.color.r, hi.color.r, frac), lerpByte(lo.color.g, hi.color.g, frac), lerpByte(lo.color.b, hi.color.b, frac), lerpByte(lo.color.a, hi.color.a, frac) - }; + }, g); } core::Rgba ColorMapService::colorAtDiscrete(double v) const { - return scale_.colorAt(v); + return applyGlobalAlpha(scale_.colorAt(v), scale_.globalOpacity()); } } // namespace geopro::app diff --git a/src/app/panels/chart/ColorScaleProperties.cpp b/src/app/panels/chart/ColorScaleProperties.cpp new file mode 100644 index 0000000..124fa88 --- /dev/null +++ b/src/app/panels/chart/ColorScaleProperties.cpp @@ -0,0 +1,57 @@ +#include "panels/chart/ColorScaleProperties.hpp" + +#include + +namespace geopro::app { + +QString rgbaToColorBarCss(const geopro::core::Rgba& c) { + if (c.a >= 255) + return QStringLiteral("#%1%2%3") + .arg(c.r, 2, 16, QLatin1Char('0')) + .arg(c.g, 2, 16, QLatin1Char('0')) + .arg(c.b, 2, 16, QLatin1Char('0')) + .toUpper(); + return QStringLiteral("rgba(%1, %2, %3, %4)") + .arg(c.r) + .arg(c.g) + .arg(c.b) + .arg(QString::number(c.a / 255.0, 'g', 3)); +} + +QJsonObject buildColorScaleProperties(const geopro::core::ColorScale& scale, + const ContourLineConfig& lineCfg, const QString& lvlSchemeType, + int logLinesCount, int equalAreaLayerCount, + bool includeLvlScheme) { + QJsonArray colorBar; + for (const auto& [value, color] : scale.stops()) + colorBar.append(QJsonArray{QString::number(value, 'f', 2), rgbaToColorBarCss(color)}); + QJsonObject lineConfig{ + {QStringLiteral("showLines"), lineCfg.lineShow}, + {QStringLiteral("color"), rgbaToColorBarCss(lineCfg.lineColor)}, + {QStringLiteral("lineType"), + lineCfg.dashed ? QStringLiteral("dashed") : QStringLiteral("solid")}}; + QJsonObject labelConfig{{QStringLiteral("showLabels"), lineCfg.labelShow}, + {QStringLiteral("color"), rgbaToColorBarCss(lineCfg.labelColor)}}; + QJsonObject props{{QStringLiteral("colorBar"), colorBar}, + {QStringLiteral("opacity"), scale.globalOpacity()}, // 两级第二级:整体透明度 + {QStringLiteral("lineConfig"), lineConfig}, + {QStringLiteral("labelConfig"), labelConfig}}; + if (includeLvlScheme) { // 等值面(网格/反演)路径:层级方案透传字段(复刻原版,整条覆盖写须带) + props[QStringLiteral("lvlSchemeType")] = lvlSchemeType; + props[QStringLiteral("logLinesCount")] = logLinesCount; + props[QStringLiteral("equalAreaLayerCount")] = equalAreaLayerCount; + } + return props; +} + +QJsonObject withColorBarAndOpacity(const QJsonObject& base, const geopro::core::ColorScale& scale) { + QJsonObject props = base; // 保留 lineConfig/labelConfig/层级方案 等加载到的原值 + QJsonArray colorBar; + for (const auto& [value, color] : scale.stops()) + colorBar.append(QJsonArray{QString::number(value, 'f', 2), rgbaToColorBarCss(color)}); + props[QStringLiteral("colorBar")] = colorBar; // 仅覆盖本次编辑的颜色 + props[QStringLiteral("opacity")] = scale.globalOpacity(); // 与整体不透明度 + return props; +} + +} // namespace geopro::app diff --git a/src/app/panels/chart/ColorScaleProperties.hpp b/src/app/panels/chart/ColorScaleProperties.hpp new file mode 100644 index 0000000..bff6e15 --- /dev/null +++ b/src/app/panels/chart/ColorScaleProperties.hpp @@ -0,0 +1,29 @@ +#pragma once +#include +#include + +#include "ContourLineDialog.hpp" // ContourLineConfig +#include "model/ColorScale.hpp" + +namespace geopro::app { + +// core::Rgba → colorBar 颜色串(不透明 #RRGGBB,半透明 rgba(r,g,b,a∈0..1)),与后端 colorBar 互通。 +QString rgbaToColorBarCss(const geopro::core::Rgba& c); + +// 组装色阶持久化 properties(colorBar[每色含 alpha] + opacity[整体透明度] + lineConfig + labelConfig +// + lvlSchemeType/logLinesCount/equalAreaLayerCount[层级方案,复刻原版透传字段])。 +// 散点/网格共用同一格式(同一条后端记录 businessCode="")。整条 properties 覆盖写,故层级字段必须带, +// 否则会清空 web 设过的值。opacity 为两级透明度的第二级。 +// includeLvlScheme=false:measurement 散点(type3) 路径,不写等值面专属的层级方案字段(对齐原版 +// scatters「仅发 colorBar/lineConfig/labelConfig」+ 桌面两级 opacity),避免向 R0 记录注入无关字段。 +QJsonObject buildColorScaleProperties(const geopro::core::ColorScale& scale, + const ContourLineConfig& lineCfg, + const QString& lvlSchemeType = QStringLiteral("normal"), + int logLinesCount = 8, int equalAreaLayerCount = 10, + bool includeLvlScheme = true); + +// load-then-save 回写(对齐原版 originPage):在加载到的原始 properties 上【只覆盖 colorBar+opacity】, +// 其余字段(lineConfig/labelConfig/层级方案)原样保留,避免散点保存清掉网格(共用同一条记录)的值。 +QJsonObject withColorBarAndOpacity(const QJsonObject& base, const geopro::core::ColorScale& scale); + +} // namespace geopro::app diff --git a/src/app/panels/chart/ContourPlotItem.cpp b/src/app/panels/chart/ContourPlotItem.cpp index 531d66d..2775a48 100644 --- a/src/app/panels/chart/ContourPlotItem.cpp +++ b/src/app/panels/chart/ContourPlotItem.cpp @@ -195,7 +195,7 @@ void ContourPlotItem::buildFillImage(const core::Grid& g, ColorMapService* svc) double v = (v00 * (1 - ti) + v10 * ti) * (1 - tj) + (v01 * (1 - ti) + v11 * ti) * tj; auto c = svc->colorAtDiscrete(v); // 离散色带 → 平滑填充带边界 - scan[px] = qRgba(c.r, c.g, c.b, c.a ? c.a : 255); + scan[px] = qRgba(c.r, c.g, c.b, c.a); // 听色阶 alpha:alpha=0 真透明(无 alpha 色阶默认 255 不受影响) } } fillImage_ = std::move(img); diff --git a/src/app/panels/chart/DetailViewFactory.cpp b/src/app/panels/chart/DetailViewFactory.cpp index bf1d3df..06f620d 100644 --- a/src/app/panels/chart/DetailViewFactory.cpp +++ b/src/app/panels/chart/DetailViewFactory.cpp @@ -17,7 +17,8 @@ std::unique_ptr makeDetailView(controller::ViewKind kind, QWidget* std::function projectIdGetter, geopro::data::IDatasetCommandRepository* cmdRepo, std::function dsIdGetter, - std::function tmObjectIdGetter) { + std::function tmObjectIdGetter, + geopro::controller::DatasetViewState* viewState) { switch (kind) { case controller::ViewKind::Scatter: { auto* raw = new RawDataChartView(parent); @@ -25,6 +26,8 @@ std::unique_ptr makeDetailView(controller::ViewKind kind, QWidget* raw->setCommandRepo(cmdRepo, dsIdGetter, projectIdGetter); // 注入色阶模板仓储(散点「色阶配置」编辑器另存为/打开/覆盖用;projectId 复用上面的 getter)。 raw->setColorTemplateRepo(colorTplRepo); + // 注入跨视图色阶真源(反演原数据 type1 与网格/3D 共用色阶 → 实时联动;measurement 不路由)。 + raw->setViewState(viewState); return std::unique_ptr(raw); } case controller::ViewKind::FilledContour: { @@ -35,6 +38,8 @@ std::unique_ptr makeDetailView(controller::ViewKind kind, QWidget* grid->setCommandRepo(cmdRepo, std::move(dsIdGetter), std::move(projectIdGetter)); // 注入 tmObjectId 取值回调(白化对话框模板列表用,= 数据集 structParentId)。 grid->setTmObjectIdGetter(std::move(tmObjectIdGetter)); + // 注入跨视图色阶真源(编辑→真源→3D 帘面/体等跟随;本视图也跟随他视图改色)。 + grid->setViewState(viewState); return std::unique_ptr(grid); } case controller::ViewKind::Table: { diff --git a/src/app/panels/chart/DetailViewFactory.hpp b/src/app/panels/chart/DetailViewFactory.hpp index da9435e..34d7115 100644 --- a/src/app/panels/chart/DetailViewFactory.hpp +++ b/src/app/panels/chart/DetailViewFactory.hpp @@ -13,6 +13,10 @@ class IColorTemplateRepository; class IDatasetCommandRepository; } +namespace geopro::controller { +class DatasetViewState; // 跨视图色阶真源(统一同步) +} + namespace geopro::app { class IDetailView; @@ -29,6 +33,7 @@ std::unique_ptr makeDetailView( std::function projectIdGetter = {}, geopro::data::IDatasetCommandRepository* cmdRepo = nullptr, std::function dsIdGetter = {}, - std::function tmObjectIdGetter = {}); + std::function tmObjectIdGetter = {}, + geopro::controller::DatasetViewState* viewState = nullptr); } // namespace geopro::app diff --git a/src/app/panels/chart/GridDataChartView.cpp b/src/app/panels/chart/GridDataChartView.cpp index 7409ee4..b2d42e4 100644 --- a/src/app/panels/chart/GridDataChartView.cpp +++ b/src/app/panels/chart/GridDataChartView.cpp @@ -36,6 +36,8 @@ #include "panels/chart/AutoAnnotationDialog.hpp" #include "panels/chart/ColorBarWidget.hpp" #include "panels/chart/ColorMapService.hpp" +#include "panels/chart/ColorScaleProperties.hpp" +#include "DatasetViewState.hpp" #include "panels/chart/ContourDrawTool.hpp" #include "panels/chart/ContourHoverTip.hpp" #include "panels/chart/ContourPlotItem.hpp" @@ -324,7 +326,7 @@ void GridDataChartView::openColorScaleEditor() { tplRepo_, projectId, lvlTemplateId_, this); if (dlg.exec() != QDialog::Accepted) return; - gridScale_ = dlg.colorScale(); + const auto cs = dlg.colorScale(); lineCfg_ = dlg.lineConfig(); showLabels_ = lineCfg_.labelShow; // 标注显隐同步 + 回写工具条复选框(避免 UI 与状态脱钩) if (chkShowLabels_) { @@ -332,10 +334,63 @@ void GridDataChartView::openColorScaleEditor() { chkShowLabels_->setChecked(showLabels_); } + const QString dsId = dsIdGetter_ ? dsIdGetter_() : QString(); + // 统一同步:写入色阶真源 → 经 colorScaleChanged 触发本视图(及 3D 帘面/体等)重渲染。 + // 无状态层(理论不至)才本地兜底重绘。 + if (state_ && !dsId.isEmpty()) { + state_->setColorScale(dsId, cs); + } else { + gridScale_ = cs; + applyColorScaleRender(); + } + persistColorScale(dsId, cs, dlg.lvlSchemeType(), dlg.logLinesCount(), + dlg.equalAreaLayerCount()); // 持久化到后端(businessCode="",与散点同一条记录) +} + +void GridDataChartView::applyColorScaleRender() { delete colorSvc_; colorSvc_ = new ColorMapService(gridScale_); rebuildContour(); - colorBar_->setColorScale(gridScale_); + if (colorBar_) colorBar_->setColorScale(gridScale_); +} + +void GridDataChartView::setViewState(geopro::controller::DatasetViewState* state) { + state_ = state; + if (!state_) return; + connect(state_, &geopro::controller::DatasetViewState::colorScaleChanged, this, + &GridDataChartView::onColorScaleChanged); +} + +void GridDataChartView::onColorScaleChanged(const QString& dsId) { + if (!state_ || !hasGrid_) return; + if (!dsIdGetter_ || dsIdGetter_() != dsId) return; // 只跟随本视图所示数据集 + const auto* cs = state_->colorScale(dsId); + if (!cs) return; + gridScale_ = *cs; + applyColorScaleRender(); +} + +void GridDataChartView::persistColorScale(const QString& dsId, const geopro::core::ColorScale& cs, + const QString& lvlSchemeType, int logLinesCount, + int equalAreaLayerCount) { + if (!cmdRepo_ || dsId.isEmpty()) return; // 无仓储/无 dsId → 仅本地生效(不阻塞) + const QString projectId = projectIdGetter_ ? projectIdGetter_() : QString(); + // 网格(getDetail type2) 与 反演散点(type1) 共用 businessCode=""(后端按 (dsObjectId,businessCode) + // 存唯一记录,save 无 type 字段)。properties 含每色 alpha + 整体透明度 + 层级方案透传字段。 + QJsonObject body{ + {QStringLiteral("dsObjectId"), dsId}, + {QStringLiteral("templateId"), lvlTemplateId_}, + {QStringLiteral("businessCode"), QString()}, + {QStringLiteral("projectId"), projectId}, + {QStringLiteral("properties"), + buildColorScaleProperties(cs, lineCfg_, lvlSchemeType, logLinesCount, equalAreaLayerCount)}, + }; + QPointer self(this); + cmdRepo_->saveColorGradation(body, [self](bool ok, QString msg) { + if (!self || ok) return; + QMessageBox::warning(self, QStringLiteral("色阶配置"), + msg.isEmpty() ? QStringLiteral("色阶保存失败") : msg); + }); } void GridDataChartView::setCommandRepo(geopro::data::IDatasetCommandRepository* repo, diff --git a/src/app/panels/chart/GridDataChartView.hpp b/src/app/panels/chart/GridDataChartView.hpp index b52139e..d4e6699 100644 --- a/src/app/panels/chart/GridDataChartView.hpp +++ b/src/app/panels/chart/GridDataChartView.hpp @@ -4,6 +4,7 @@ #include #include // submitDrawnException 默认参数 const QJsonObject& = {} 需完整类型 +#include #include #include @@ -28,6 +29,10 @@ class IColorTemplateRepository; class IDatasetCommandRepository; } +namespace geopro::controller { +class DatasetViewState; // 跨视图色阶真源(统一同步机制) +} + namespace geopro::app { class AnomalyTablePanel; @@ -71,9 +76,17 @@ public: tmObjectIdGetter_ = std::move(tmObjectIdGetter); } + // 注入跨视图色阶真源(统一同步):编辑写入它、并连 colorScaleChanged 跟随他视图(如 3D)改色。 + void setViewState(geopro::controller::DatasetViewState* state); + private: void rebuildContour(); // 按当前显隐开关重建并重绘 ContourPlotItem void openColorScaleEditor(); // 「色阶配置」→ 共享色阶编辑器(色阶 + 层级⚙ + 线形⚙) + void applyColorScaleRender(); // 用当前 gridScale_ 重建色彩服务/等值面/色阶条 + void onColorScaleChanged(const QString& dsId); // 色阶真源变更(本视图或他视图编辑)→ 跟随重渲染 + void persistColorScale(const QString& dsId, const geopro::core::ColorScale& cs, + const QString& lvlSchemeType, int logLinesCount, + int equalAreaLayerCount); // 存后端(含层级方案透传字段) void openGridWizard(); // I1「网格」→ 网格化向导 void openWhitening(); // I3「白化」→ 白化弹窗 void openFilter(); // I4「滤波处理」→ 滤波弹窗 @@ -131,6 +144,8 @@ private: // tmObjectId 取值回调(= 数据集 structParentId)。白化对话框模板列表用;空 → 模板列表为空。 std::function tmObjectIdGetter_; + + QPointer state_; // 跨视图色阶真源(注入;QPointer 自动判空防悬挂) }; } // namespace geopro::app diff --git a/src/app/panels/chart/RawDataChartView.cpp b/src/app/panels/chart/RawDataChartView.cpp index b20f6a5..7b33bb8 100644 --- a/src/app/panels/chart/RawDataChartView.cpp +++ b/src/app/panels/chart/RawDataChartView.cpp @@ -2,6 +2,8 @@ #include "ColorScaleConfigDialog.hpp" #include "panels/chart/ChartTheme.hpp" #include "panels/chart/ColorBarWidget.hpp" +#include "panels/chart/ColorScaleProperties.hpp" +#include "DatasetViewState.hpp" #include "panels/chart/GridWizardDialog.hpp" #include "panels/chart/InversionFormDialog.hpp" #include "panels/chart/SaveAsDialog.hpp" @@ -302,41 +304,6 @@ void styleToolIconButton(QToolButton* btn, const QIcon& icon) { btn->setCursor(Qt::PointingHandCursor); } -// core::Rgba → colorBar 颜色串(与 ColorScaleConfigDialog::rgbaToCss 同格式:不透明 #RRGGBB, -// 半透明 rgba(r,g,b,a∈0..1)),与后端 colorBar 互通。 -QString rgbaToColorBarCss(const geopro::core::Rgba& c) { - if (c.a >= 255) - return QStringLiteral("#%1%2%3") - .arg(c.r, 2, 16, QLatin1Char('0')) - .arg(c.g, 2, 16, QLatin1Char('0')) - .arg(c.b, 2, 16, QLatin1Char('0')) - .toUpper(); - return QStringLiteral("rgba(%1, %2, %3, %4)") - .arg(c.r) - .arg(c.g) - .arg(c.b) - .arg(QString::number(c.a / 255.0, 'g', 3)); -} - -// 组装色阶 properties(colorBar + lineConfig + labelConfig),与原版散点路径 -// newLvlColorLevel 一致(battery/scatters 仅发这三块,不含 lvlSchemeType 等等值面专属字段)。 -QJsonObject buildColorScaleProperties(const geopro::core::ColorScale& scale, - const ContourLineConfig& lineCfg) { - QJsonArray colorBar; - for (const auto& [value, color] : scale.stops()) - colorBar.append(QJsonArray{QString::number(value, 'f', 2), rgbaToColorBarCss(color)}); - QJsonObject lineConfig{ - {QStringLiteral("showLines"), lineCfg.lineShow}, - {QStringLiteral("color"), rgbaToColorBarCss(lineCfg.lineColor)}, - {QStringLiteral("lineType"), - lineCfg.dashed ? QStringLiteral("dashed") : QStringLiteral("solid")}}; - QJsonObject labelConfig{{QStringLiteral("showLabels"), lineCfg.labelShow}, - {QStringLiteral("color"), rgbaToColorBarCss(lineCfg.labelColor)}}; - return QJsonObject{{QStringLiteral("colorBar"), colorBar}, - {QStringLiteral("lineConfig"), lineConfig}, - {QStringLiteral("labelConfig"), labelConfig}}; -} - } // namespace void RawDataChartView::showNotImplemented(QWidget* anchor) { @@ -400,16 +367,21 @@ void RawDataChartView::openInversionColorScale(QWidget* anchor) { this); if (dlg.exec() != QDialog::Accepted) return; - // 本地重建上色重绘。 - data_.scale = dlg.colorScale(); - delete colorSvc_; - colorSvc_ = new ColorMapService(data_.scale); - redrawScatter(); - colorBar_->setColorScale(data_.scale); + const auto cs = dlg.colorScale(); + const QString dsId = dsIdGetter_ ? dsIdGetter_() : QString(); + // 统一同步:反演原数据(type1,"")与网格/3D 共用 → 写真源,经 colorScaleChanged 各视图(含自身)重绘。 + if (state_ && !dsId.isEmpty()) { + state_->setColorScale(dsId, cs); + } else { + data_.scale = cs; + delete colorSvc_; + colorSvc_ = new ColorMapService(data_.scale); + redrawScatter(); + colorBar_->setColorScale(data_.scale); + } showToast(this, QStringLiteral("色阶应用成功")); // M8 成功提示(对照原版 Message.success) // 持久化(businessCode 空,对照原版 originPage newLvlColorLevel businessCode:'')。 - const QString dsId = dsIdGetter_ ? dsIdGetter_() : QString(); const QString projectId = projectIdGetter_ ? projectIdGetter_() : QString(); if (!cmdRepo_ || dsId.isEmpty()) return; QJsonObject body{ @@ -417,7 +389,13 @@ void RawDataChartView::openInversionColorScale(QWidget* anchor) { {QStringLiteral("templateId"), data_.templateId}, // 读取到的色阶模板 id(对照原版,可空) {QStringLiteral("businessCode"), QString()}, {QStringLiteral("projectId"), projectId}, - {QStringLiteral("properties"), buildColorScaleProperties(data_.scale, dlg.lineConfig())}, + // load-then-save:在加载到的原始 properties 上只覆盖 colorBar+opacity,保留网格设过的 + // lineConfig/层级(共用同一条 businessCode="" 记录);无原始记录(首次)才整条新建。 + {QStringLiteral("properties"), + data_.properties.isEmpty() + ? buildColorScaleProperties(cs, dlg.lineConfig(), dlg.lvlSchemeType(), + dlg.logLinesCount(), dlg.equalAreaLayerCount()) + : withColorBarAndOpacity(data_.properties, cs)}, }; QPointer self(this); cmdRepo_->saveColorGradation(body, [self](bool ok, QString msg) { @@ -427,6 +405,26 @@ void RawDataChartView::openInversionColorScale(QWidget* anchor) { }); } +void RawDataChartView::setViewState(geopro::controller::DatasetViewState* state) { + state_ = state; + if (!state_) return; + connect(state_, &geopro::controller::DatasetViewState::colorScaleChanged, this, + &RawDataChartView::onColorScaleChanged); +} + +void RawDataChartView::onColorScaleChanged(const QString& dsId) { + if (!state_) return; + if (!dsIdGetter_ || dsIdGetter_() != dsId) return; // 只跟随本视图所示数据集 + const auto* cs = state_->colorScale(dsId); + if (!cs) return; + data_.scale = *cs; + delete colorSvc_; + colorSvc_ = new ColorMapService(data_.scale); + redrawScatter(); + if (data_.verticalLegend) colorBarV_->setColorScale(data_.scale); + else colorBar_->setColorScale(data_.scale); +} + void RawDataChartView::openInversionSaveAs(QWidget* anchor) { // O3:另存为(复用 SaveAsDialog::Inversion → saveInversionAsData)。 const QString dsId = dsIdGetter_ ? dsIdGetter_() : QString(); @@ -599,7 +597,10 @@ void RawDataChartView::openScatterColorScale(QWidget* anchor) { {QStringLiteral("templateId"), data_.templateId}, // 读取到的色阶模板 id(对照原版,可空) {QStringLiteral("businessCode"), currentVFieldCode()}, {QStringLiteral("projectId"), projectId}, - {QStringLiteral("properties"), buildColorScaleProperties(data_.scale, dlg.lineConfig())}, + // measurement(type3,"R0") 独立记录:不写等值面层级方案字段(includeLvlScheme=false)。 + {QStringLiteral("properties"), + buildColorScaleProperties(data_.scale, dlg.lineConfig(), QStringLiteral("normal"), 8, 10, + /*includeLvlScheme=*/false)}, }; QPointer self(this); cmdRepo_->saveColorGradation(body, [self](bool ok, QString msg) { diff --git a/src/app/panels/chart/RawDataChartView.hpp b/src/app/panels/chart/RawDataChartView.hpp index f354f93..c5e7647 100644 --- a/src/app/panels/chart/RawDataChartView.hpp +++ b/src/app/panels/chart/RawDataChartView.hpp @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include #include "model/detail/DetailPayloads.hpp" @@ -18,6 +19,10 @@ class IDatasetCommandRepository; class IColorTemplateRepository; } +namespace geopro::controller { +class DatasetViewState; // 跨视图色阶真源(统一同步) +} + namespace geopro::app { class ColorBarWidget; @@ -52,6 +57,10 @@ public: // setCommandRepo 注入的 projectIdGetter_)。可传空 → 编辑器后端按钮禁用。 void setColorTemplateRepo(geopro::data::IColorTemplateRepository* repo); + // 注入跨视图色阶真源:反演原数据散点(type1, businessCode="")与网格/3D 共用色阶 → 实时联动。 + // measurement(type3,"R0") 单视图、不路由真源(openScatterColorScale 不经此)。 + void setViewState(geopro::controller::DatasetViewState* state); + protected: // 信息模式(M13)下捕获画布点击:找最近散点显示属性。其余事件不消费。 bool eventFilter(QObject* obj, QEvent* ev) override; @@ -70,6 +79,7 @@ private: // 反演原数据默认工具条交互(O1/O2/O3): void openGridWizard(QWidget* anchor); // O1 网格化向导(复用 GridWizardDialog) void openInversionColorScale(QWidget* anchor); // O2 原数据散点色阶(type1,businessCode='') + void onColorScaleChanged(const QString& dsId); // 色阶真源变更(本视图或网格/3D)→ type1 散点跟随重绘 void openInversionSaveAs(QWidget* anchor); // O3 另存为(复用 SaveAsDialog::Inversion) // measurement 交互: @@ -130,6 +140,8 @@ private: std::function projectIdGetter_; // 色阶模板仓储(注入;空则编辑器「另存为/打开」禁用)。 geopro::data::IColorTemplateRepository* colorTplRepo_ = nullptr; + + QPointer state_; // 跨视图色阶真源(注入;仅 type1 路由;QPointer 防悬挂) }; } // namespace geopro::app diff --git a/src/controller/CMakeLists.txt b/src/controller/CMakeLists.txt index 54a5a11..2f57347 100644 --- a/src/controller/CMakeLists.txt +++ b/src/controller/CMakeLists.txt @@ -2,6 +2,7 @@ find_package(Qt6 COMPONENTS Core REQUIRED) add_library(geopro_controller STATIC WorkbenchNavController.cpp DatasetDetailController.cpp + DatasetViewState.cpp VtkSceneController.cpp) target_include_directories(geopro_controller PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) target_link_libraries(geopro_controller PUBLIC geopro_data Qt6::Core) diff --git a/src/controller/DatasetViewState.cpp b/src/controller/DatasetViewState.cpp new file mode 100644 index 0000000..7f23588 --- /dev/null +++ b/src/controller/DatasetViewState.cpp @@ -0,0 +1,2 @@ +#include "DatasetViewState.hpp" +// 实现全部内联于头;此 .cpp 仅为让 AUTOMOC 为带 Q_OBJECT 的头生成并链接 moc。 diff --git a/src/controller/DatasetViewState.hpp b/src/controller/DatasetViewState.hpp new file mode 100644 index 0000000..ad0b4ce --- /dev/null +++ b/src/controller/DatasetViewState.hpp @@ -0,0 +1,49 @@ +#pragma once +#include +#include +#include + +#include "model/ColorScale.hpp" + +namespace geopro::controller { + +// 跨视图共享的「单一真源」会话状态,按 dsId 维护。统一所有视图间同步,取代两两接线: +// - 改色阶:任何编辑入口只调 setColorScale(dsId, cs),不再各改各的拷贝; +// - 观察:各视图连一次 colorScaleChanged(dsId),槽里【只重渲染】、【绝不回写】→ 无信号回环; +// - 加载:视图取色阶时先问 hub(colorScale 非空则用之),否则把后端值 seed 进来当真源。 +// 新增同步项(可见性/选中/值域…)= 加一个字段 + 一个 xxxChanged(dsId) 信号,沿用同一套机制。 +// +// 作用域:数据集的「默认/共享色阶」(后端 businessCode=""),被 反演散点/网格/帘面/体 共用(同一条后端 +// 记录)。measurement(businessCode="R0") 为单视图、无跨视图伙伴,暂不入此层(清晰边界,非欠债)。 +class DatasetViewState : public QObject { + Q_OBJECT +public: + explicit DatasetViewState(QObject* parent = nullptr) : QObject(parent) {} + + bool hasColorScale(const QString& dsId) const { return scales_.contains(dsId); } + + // 无记录返回 nullptr(调用方据此兜底为自带值)。 + const geopro::core::ColorScale* colorScale(const QString& dsId) const { + auto it = scales_.constFind(dsId); + return it == scales_.constEnd() ? nullptr : &it.value(); + } + + // 用户编辑应用:写入真源并广播。观察者据此重渲染。 + void setColorScale(const QString& dsId, const geopro::core::ColorScale& cs) { + scales_.insert(dsId, cs); + emit colorScaleChanged(dsId); + } + + // 首次从后端加载得到色阶时播种:已有则不覆盖、不广播(避免加载即触发重建/存盘)。 + void seedColorScale(const QString& dsId, const geopro::core::ColorScale& cs) { + if (!scales_.contains(dsId)) scales_.insert(dsId, cs); + } + +signals: + void colorScaleChanged(const QString& dsId); + +private: + QHash scales_; +}; + +} // namespace geopro::controller diff --git a/src/controller/I3dSceneView.hpp b/src/controller/I3dSceneView.hpp index d0cebde..06e3118 100644 --- a/src/controller/I3dSceneView.hpp +++ b/src/controller/I3dSceneView.hpp @@ -44,6 +44,10 @@ public: // 3D:体绘制(IDW 体素 + colorScale);按 dsId 跟踪。 virtual void addVolume(const std::string& dsId, const geopro::data::VolumeGrid& vol, const geopro::core::ColorScale& cs) = 0; + // 原地更新已渲染体颜色/不透明度(仅换传函、不重建 image):色阶改动用,避免换 image 连带关闭未保存切片。 + // 返回 true=已原地更新;false=该体未渲染/不支持 → 调用方回退 remove+add。默认 false。 + virtual bool updateVolumeColorInPlace(const std::string& /*dsId*/, + const geopro::core::ColorScale& /*cs*/) { return false; } // 2D 足迹:把测线/轨迹经纬折线平铺进 3D 地图(worldZ=摆放高程);按 dsId 跟踪以支持增量移除。 virtual void addMapLine(const std::string& dsId, const geopro::data::MapLine& line, double worldZ) = 0; diff --git a/src/controller/VtkSceneController.cpp b/src/controller/VtkSceneController.cpp index 16aefce..c41ef4b 100644 --- a/src/controller/VtkSceneController.cpp +++ b/src/controller/VtkSceneController.cpp @@ -7,6 +7,7 @@ #include #include +#include "DatasetViewState.hpp" #include "I3dSceneView.hpp" #include "repo/IDatasetRepository.hpp" @@ -24,6 +25,39 @@ VtkSceneController::VtkSceneController(data::IDatasetRepository& dsRepo, QObject* parent) : QObject(parent), dsRepo_(dsRepo), sceneRepo_(sceneRepo), view_(view) {} +void VtkSceneController::setViewState(DatasetViewState* state) { + state_ = state; + if (state_) + connect(state_, &DatasetViewState::colorScaleChanged, this, + &VtkSceneController::recolorDataset); +} + +void VtkSceneController::recolorDataset(const QString& qid) { + if (!state_) return; + const geopro::core::ColorScale* cs = state_->colorScale(qid); + if (!cs) return; + const std::string dsId = qid.toStdString(); + volumeScaleCache_[dsId] = *cs; // 体色阶随真源更新(未渲染时下次勾选命中) + if (!isChecked(dsId)) return; // 未渲染 → 仅更缓存 + // 就地重建:体 → 用新色阶重 addVolume(addVolume 内部触发体下切片随新色阶重建); + // 帘面 → 用缓存源网格重 addCurtain。一个 dsId 只会是其一。 + bool changed = false; + if (auto vit = volumeCache_.find(dsId); vit != volumeCache_.end()) { + // 优先原地改色(仅换传函、不重建 image)→ 该体下未保存切片不被关闭、跟随改色。 + // 原地失败(理论不至)才回退 remove+add(会关未保存切片)。 + if (!view_.updateVolumeColorInPlace(dsId, *cs)) { + view_.removeDataset(dsId); + view_.addVolume(dsId, vit->second, *cs); + } + changed = true; + } else if (auto sit = sectionGridCache_.find(dsId); sit != sectionGridCache_.end()) { + view_.removeDataset(dsId); + view_.addCurtain(dsId, sit->second, *cs); + changed = true; + } + if (changed) view_.renderIncremental(); +} + void VtkSceneController::setCheckedDatasets(const QStringList& dsIds) { std::vector newDs; newDs.reserve(static_cast(dsIds.size())); @@ -149,7 +183,10 @@ void VtkSceneController::addDatasetAsync(const std::string& dsId, unsigned long auto cachedGrid = volumeCache_.find(dsId); auto cachedScale = volumeScaleCache_.find(dsId); if (cachedGrid != volumeCache_.end() && cachedScale != volumeScaleCache_.end()) { - view_.addVolume(dsId, cachedGrid->second, cachedScale->second); // 缓存命中(色阶随体缓存) + const QString qid = QString::fromStdString(dsId); // 优先用色阶真源(含已编辑值) + const geopro::core::ColorScale& useCs = + (state_ && state_->colorScale(qid)) ? *state_->colorScale(qid) : cachedScale->second; + view_.addVolume(dsId, cachedGrid->second, useCs); // 缓存命中(色阶随体缓存) onDatasetArrived(); emit volumeRendered(QString::fromStdString(dsId)); // 缓存命中即时完成 → 撤 spinner emit datasetRendered(QString::fromStdString(dsId)); @@ -164,11 +201,16 @@ void VtkSceneController::addDatasetAsync(const std::string& dsId, unsigned long self->loadingDs_.erase(dsId); if (gen != self->rebuildGeneration_ || !self->isChecked(dsId)) return; self->volumeScaleCache_[dsId] = cs; // 色阶随体一起缓存(mock 体在 dsRepo_ 无条目) + const QString qid = QString::fromStdString(dsId); + if (self->state_) self->state_->seedColorScale(qid, cs); // 播种真源 auto it = self->volumeCache_.emplace(dsId, std::move(g)).first; - qInfo().noquote() << "[volrender] addVolume dsId=" << QString::fromStdString(dsId) + qInfo().noquote() << "[volrender] addVolume dsId=" << qid << "nx=" << it->second.vol.nx() << "ny=" << it->second.vol.ny() << "nz=" << it->second.vol.nz(); - self->view_.addVolume(dsId, it->second, self->volumeScaleCache_[dsId]); + const geopro::core::ColorScale& useCs = + (self->state_ && self->state_->colorScale(qid)) ? *self->state_->colorScale(qid) + : self->volumeScaleCache_[dsId]; + self->view_.addVolume(dsId, it->second, useCs); self->onDatasetArrived(); emit self->volumeRendered(QString::fromStdString(dsId)); // 落地完成 → 撤 spinner emit self->datasetRendered(QString::fromStdString(dsId)); @@ -191,7 +233,13 @@ void VtkSceneController::addDatasetAsync(const std::string& dsId, unsigned long if (!self) return; self->loadingDs_.erase(dsId); if (gen != self->rebuildGeneration_ || !self->isChecked(dsId)) return; // 作废/已取消 - self->view_.addCurtain(dsId, s.grid, s.scale); + self->sectionGridCache_.insert_or_assign(dsId, s.grid); // 留存源网格供帘面重着色(Grid 无默认构造) + const QString qid = QString::fromStdString(dsId); + if (self->state_) self->state_->seedColorScale(qid, s.scale); // 播种真源 + const geopro::core::ColorScale& useCs = + (self->state_ && self->state_->colorScale(qid)) ? *self->state_->colorScale(qid) + : s.scale; + self->view_.addCurtain(dsId, s.grid, useCs); self->onDatasetArrived(); emit self->datasetRendered(QString::fromStdString(dsId)); // 帘面落地 → 复原复选框 }, @@ -260,12 +308,16 @@ void VtkSceneController::rebuild() { rebuildInternal(); } void VtkSceneController::setVolumeColorScale(const std::string& dsId, const geopro::core::ColorScale& cs) { - volumeScaleCache_[dsId] = cs; // 会话级 mock 持久(再勾选命中缓存,见 addDatasetAsync) - if (!isChecked(dsId)) return; // 未渲染 → 仅更缓存,下次勾选生效 + // 统一走色阶真源:写入即广播 colorScaleChanged → recolorDataset 就地重着色(体/帘面+切片), + // 同时 2D 详情等其它视图一并跟随。无 state_(理论不至)才退化为直连重建。 + if (state_) { + state_->setColorScale(QString::fromStdString(dsId), cs); + return; + } + volumeScaleCache_[dsId] = cs; + if (!isChecked(dsId)) return; auto git = volumeCache_.find(dsId); - if (git == volumeCache_.end()) return; // 体网格尚未到场 → 同上 - // 移除旧体素 → 以新色阶重建:addVolume 内部置 currentColorScale_ 并触发 onVolumeChanged, - // InteractionManager 据此以新色阶重建该体下已勾选切片。 + if (git == volumeCache_.end()) return; view_.removeDataset(dsId); view_.addVolume(dsId, git->second, cs); view_.renderIncremental(); diff --git a/src/controller/VtkSceneController.hpp b/src/controller/VtkSceneController.hpp index 88d85ce..5a03f63 100644 --- a/src/controller/VtkSceneController.hpp +++ b/src/controller/VtkSceneController.hpp @@ -1,5 +1,6 @@ #pragma once #include +#include #include #include #include @@ -18,6 +19,8 @@ class IDatasetRepository; namespace geopro::controller { +class DatasetViewState; // 跨视图共享色阶真源(统一同步机制) + // 中央视图模式:二维地图(俯视测线)/ 三维视图(帘面/体素/地形)。 enum class ViewMode { Map2D, View3D }; @@ -35,6 +38,10 @@ public: VtkSceneController(data::IDatasetRepository& dsRepo, data::I3dSceneRepository& sceneRepo, I3dSceneView& view, QObject* parent = nullptr); + // 注入跨视图色阶真源(统一同步):连 colorScaleChanged → 就地按 dsId 重着色帘面/体。 + // 构造后由 main.cpp 注入一次。 + void setViewState(DatasetViewState* state); + public slots: void setCheckedDatasets(const QStringList& dsIds); // 二维数据集栏勾选(足迹型测线/轨迹)→ 平铺进 View3D 地图。与 3D 勾选集独立,按 dsId 增量。 @@ -51,8 +58,8 @@ public slots: void setVolumeOpacity(double maxOpacity); void rebuild(); // 主题切换等外部触发的重渲染 - // 色阶编辑器「确定」:更新某三维体色阶并就地重渲染(体素 + 其切片随新色阶重建)。 - // 后端 3D 色阶保存未就绪 → 缓存即会话级 mock 持久(再勾选命中 volumeScaleCache_)。 + // 色阶编辑器「确定」:写入色阶真源(state_),经 colorScaleChanged 统一就地重着色(体/帘面 + 切片)。 + // 兼容旧调用点;真正的重着色在 recolorDataset()。无 state_ 时退化为直连重建。 void setVolumeColorScale(const std::string& dsId, const geopro::core::ColorScale& cs); // ── P2 三维数据集栏 ── @@ -77,6 +84,8 @@ signals: private: void rebuildInternal(); + // colorScaleChanged(dsId) 槽:从 state_ 取新色阶,就地重建该 dsId 的帘面/体(及体下切片)。只渲染,不回写。 + void recolorDataset(const QString& dsId); // 增量加入单个 ds(帘面/体素,按图层开关);回调按 gen + 仍勾选 守护,落地后增量渲染。 void addDatasetAsync(const std::string& dsId, unsigned long long gen); // 增量加入单个 2D 足迹(异步 loadMapLine → addMapLine at 当前摆放 Z);回调按 gen + 仍勾选 守护。 @@ -113,9 +122,13 @@ private: AxisRangeCfg axisX_, axisY_, axisZ_; // 坐标轴设置面板的 per-axis 可见性 + 自定义范围 static constexpr int kAxesFontSize = 12; + QPointer state_; // 跨视图色阶真源(注入;编辑/加载/重着色都经它;QPointer 防悬挂) + // 缓存(按 dsId):避免重复读盘/插值。 std::map gridCache_; std::map colorScaleCache_; + // 帘面源网格缓存:帘面重着色需 grid 重建 addCurtain(loadSection 的 s.grid 不在 gridCache_)。 + std::map sectionGridCache_; std::map volumeCache_; // 三维体色阶缓存:mock 体在 dsRepo_ 无条目,色阶随 loadVolume 一起交付并缓存于此。 std::map volumeScaleCache_; diff --git a/src/core/model/ColorScale.hpp b/src/core/model/ColorScale.hpp index db793a7..baecd3c 100644 --- a/src/core/model/ColorScale.hpp +++ b/src/core/model/ColorScale.hpp @@ -23,10 +23,16 @@ public: void setOver(Rgba c) { over_ = c; } void setNan(Rgba c) { nan_ = c; } bool empty() const { return stops_.empty(); } + + // 整体透明度(两级透明度的第二级):与每色 alpha 相乘,渲染时才叠加,绝不烘焙进 stop。 + // [0,1],默认 1(不透明)。独立存储 → 色阶编辑可回显真实值、单色 alpha 保持独立。 + void setGlobalOpacity(double o) { globalOpacity_ = o; } + double globalOpacity() const { return globalOpacity_; } private: struct Stop { double value; Rgba color; }; std::vector stops_; std::optional under_, over_, nan_; + double globalOpacity_ = 1.0; }; } // namespace geopro::core diff --git a/src/core/model/detail/DetailPayloads.hpp b/src/core/model/detail/DetailPayloads.hpp index 749db53..df2e21e 100644 --- a/src/core/model/detail/DetailPayloads.hpp +++ b/src/core/model/detail/DetailPayloads.hpp @@ -1,5 +1,6 @@ #pragma once #include +#include #include #include #include "model/Field.hpp" @@ -41,6 +42,9 @@ struct ScatterPayload { // 色阶模板 id(来自 lvl/colorGradation/getDetail 的 templateId):保存色阶时回带 // (对照原版 newLvlColorLevel 带读取到的 templateId;可空)。 QString templateId; + // type1 原始 properties(lineConfig/labelConfig/层级方案):保存色阶时只覆盖 colorBar+opacity、 + // 其余原样回写,避免清掉网格(共用同一条 businessCode="" 记录)设过的等值线/层级值(对齐原版 load-then-save)。 + QJsonObject properties; }; // 等值面载荷:grid(rows) + 色阶 + 异常(≈ data::GridParts)。 diff --git a/src/data/api/Api3dRepository.cpp b/src/data/api/Api3dRepository.cpp index 26a12f5..5e16bac 100644 --- a/src/data/api/Api3dRepository.cpp +++ b/src/data/api/Api3dRepository.cpp @@ -477,6 +477,21 @@ void Api3dRepository::deleteSlice(const std::string& dsId, std::function onOk(); } +void Api3dRepository::setSliceColorScale(const std::string& dsId, + const geopro::core::ColorScale& cs) { + auto it = slices_.find(dsId); + if (it == slices_.end()) return; + it->second.colorScale = cs; // 切片独立色阶(mock;真实后端走该切片 dsId 的 colorGradation) + it->second.hasColorScale = true; +} + +bool Api3dRepository::sliceColorScale(const std::string& dsId, geopro::core::ColorScale& out) const { + auto it = slices_.find(dsId); + if (it == slices_.end() || !it->second.hasColorScale) return false; + out = it->second.colorScale; + return true; +} + // ── 异常 / 异常体(后端真实端点存在,但异常挂三维体、三维体仍 mock → 异常暂内存 mock; // 挂载结构按"异常→三维体",整链端点就绪后切真实,见记忆 vtk-3d-persistence-structure)── diff --git a/src/data/api/Api3dRepository.hpp b/src/data/api/Api3dRepository.hpp index 77e0ee5..6fe5416 100644 --- a/src/data/api/Api3dRepository.hpp +++ b/src/data/api/Api3dRepository.hpp @@ -98,6 +98,8 @@ public: std::function onOk, OnError onErr) override; void deleteSlice(const std::string& dsId, std::function onOk, OnError onErr) override; + void setSliceColorScale(const std::string& dsId, const geopro::core::ColorScale& cs) override; + bool sliceColorScale(const std::string& dsId, geopro::core::ColorScale& out) const override; // 异常 / 异常体(后端未就绪 → load 回空树,变更走 onErr) void loadAnomalyTree(const std::string& objectId, @@ -151,6 +153,8 @@ private: SliceSpec spec; std::string name; std::string createTime; // 创建时刻 + geopro::core::ColorScale colorScale; // 切片自己的色阶(颜色快照 + 不透明度并入 globalOpacity) + bool hasColorScale = false; // 是否已设过独立色阶(否则还原时跟随三维体) }; std::map slices_; // dsId → 切片 int sliceCounter_ = 0; diff --git a/src/data/api/ApiDatasetRepository.cpp b/src/data/api/ApiDatasetRepository.cpp index a1f75e0..f849e91 100644 --- a/src/data/api/ApiDatasetRepository.cpp +++ b/src/data/api/ApiDatasetRepository.cpp @@ -28,6 +28,7 @@ struct ChartParts { geopro::core::ScatterField scatter; geopro::core::ColorScale scatterScale; QString templateId; // 散点色阶模板 id(保存色阶回带,对照原版 lvlTemplateId) + QJsonObject scatterProperties; // type1 记录原始 properties(保存时回写 lineConfig/层级,不覆盖网格的值) }; // 网格数据加载结果:grid(rows) + 网格色阶(type2) + 异常。 struct GridParts { @@ -61,6 +62,8 @@ ChartParts parseScatterParts(const QList& r) { p.scatter = dto::parseScatterGraph(r[0].data); p.scatterScale = dto::parseColorBar(r[1].data); p.templateId = r[1].data.value(QStringLiteral("templateId")).toVariant().toString(); + // 原始 properties(含 lineConfig/labelConfig/层级方案):保存色阶时原样回写,避免清掉网格(共用同条记录)的值。 + p.scatterProperties = r[1].data.value(QStringLiteral("properties")).toObject(); return p; } @@ -175,6 +178,7 @@ DetailLoad* ApiDatasetRepository::makeInversionScatter(const std::string& dsId) ChartParts p = parseScatterParts(r); core::ScatterPayload payload{p.scatter, p.scatterScale}; payload.templateId = p.templateId; // 色阶保存回带(对照原版 lvlTemplateId) + payload.properties = p.scatterProperties; // 原始 properties → 保存时回写非 colorBar 字段 return QVariant::fromValue(payload); }); } diff --git a/src/data/dto/DatasetChartDto.cpp b/src/data/dto/DatasetChartDto.cpp index 53120c7..7b5bc7e 100644 --- a/src/data/dto/DatasetChartDto.cpp +++ b/src/data/dto/DatasetChartDto.cpp @@ -47,7 +47,10 @@ ScatterField parseScatterGraph(const QJsonObject& data) { ColorScale parseColorBar(const QJsonObject& data) { ColorScale cs; - const QJsonArray bar = data.value("properties").toObject().value("colorBar").toArray(); + const QJsonObject props = data.value("properties").toObject(); + // 整体透明度(两级第二级):properties.opacity,缺省 1(不透明)。 + if (props.contains("opacity")) cs.setGlobalOpacity(props.value("opacity").toDouble(1.0)); + const QJsonArray bar = props.value("colorBar").toArray(); for (auto e : bar) { const QJsonArray pair = e.toArray(); if (pair.size() < 2) continue; diff --git a/src/data/repo/I3dSceneRepository.hpp b/src/data/repo/I3dSceneRepository.hpp index 95e0953..87e6d6d 100644 --- a/src/data/repo/I3dSceneRepository.hpp +++ b/src/data/repo/I3dSceneRepository.hpp @@ -113,6 +113,13 @@ public: virtual void deleteSlice(const std::string& dsId, std::function onOk, OnError onErr) = 0; + // 已保存切片的独立色阶(颜色快照 + 不透明度并入 globalOpacity)。mock 仓储内存存; + // 默认空实现(无色阶存储的仓储)。set 在保存切片后调用,get 在还原/编辑切片色阶时用。 + virtual void setSliceColorScale(const std::string& /*dsId*/, + const geopro::core::ColorScale& /*cs*/) {} + virtual bool sliceColorScale(const std::string& /*dsId*/, + geopro::core::ColorScale& /*out*/) const { return false; } + // ── 异常 / 异常体(spec §6.4)──────────────────────────────────────────── // 异常体(树中间层):含该体下的多个 Anomaly。 struct AnomalyBody { diff --git a/src/render/ColorLutBuilder.cpp b/src/render/ColorLutBuilder.cpp index 45d067a..637785a 100644 --- a/src/render/ColorLutBuilder.cpp +++ b/src/render/ColorLutBuilder.cpp @@ -2,19 +2,34 @@ namespace geopro::render { -vtkSmartPointer buildLut(const geopro::core::ColorScale& cs, double vmin, double vmax, int n) +vtkSmartPointer buildLut(const geopro::core::ColorScale& cs, double vmin, double vmax, + int n, bool transparentBelowRange) { if (n < 2) n = 2; // 至少两级,避免 (n-1) 退化 auto lut = vtkSmartPointer::New(); lut->SetNumberOfTableValues(n); - lut->SetTableRange(vmin, vmax); + + // 白化(无数据)真透明:体把留空格设为哨兵 vmin-1.0(< vmin),切片 reslice 后据此识别。 + // ⚠ 实测(tests/spike/slice_alpha_probe):vtkImagePlaneWidget 纹理【认】区间内 texel alpha + // (alpha=0 的格→透明,背后透出),但【不认】UseBelowRangeColor(下溢被钳到 0 号最低色格、 + // 填蓝,根本不走 below-range 色)。故不能用 UseBelowRangeColor,改为:把下限下移一格、 + // 预留 0 号格为全透明"白化槽"——下溢哨兵被钳到 0 号格即透明;真实 [vmin,vmax] 数据落 + // 1..n-1 格(不透明),不受影响。 + const double lo = transparentBelowRange ? vmin - (vmax - vmin) / (n - 1) : vmin; + lut->SetTableRange(lo, vmax); for (int t = 0; t < n; ++t) { - const double val = vmin + (vmax - vmin) * t / (n - 1); + if (transparentBelowRange && t == 0) { + lut->SetTableValue(0, 0.0, 0.0, 0.0, 0.0); // 白化槽:全透明(下溢钳到此) + continue; + } + const double val = lo + (vmax - lo) * t / (n - 1); const auto c = cs.colorAt(val); - // 复刻原版 three 渲染(parseColor 只取 rgb、MeshBasicMaterial opacity=1): - // 忽略 colorBar 的 alpha,画满不透明 RGB。 - lut->SetTableValue(t, c.r / 255.0, c.g / 255.0, c.b / 255.0, 1.0); + // 两级透明度(渲染时相乘,不烘焙):有效 alpha = 每色 alpha × 整体透明度。 + // #RRGGBB 无 alpha 默认 a=255、整体默认 1 → 旧纯色阶不受影响;alpha=0 真透明。 + lut->SetTableValue(t, c.r / 255.0, c.g / 255.0, c.b / 255.0, + c.a / 255.0 * cs.globalOpacity()); } + if (transparentBelowRange) lut->SetNanColor(0.0, 0.0, 0.0, 0.0); // NaN 也透明(双保险) lut->Build(); return lut; } diff --git a/src/render/ColorLutBuilder.hpp b/src/render/ColorLutBuilder.hpp index 5335a15..b884017 100644 --- a/src/render/ColorLutBuilder.hpp +++ b/src/render/ColorLutBuilder.hpp @@ -5,6 +5,10 @@ namespace geopro::render { // 由 core 阶梯色阶构建 N 级 vtkLookupTable,区间 [vmin, vmax]。 -vtkSmartPointer buildLut(const geopro::core::ColorScale& cs, double vmin, double vmax, int n = 256); +// transparentBelowRange=true:把 < vmin 的标量(=三维体无数据格的留空哨兵 vmin-1.0)映射为 +// 全透明(而非默认钳到最低档色不透明)。切片复用体的标量 image,据此让白化区真透明、不填蓝。 +// 仅切片需要;散点/等值线传 false(保留"低于值域钳最低色"的原行为,避免误隐真实欠量数据)。 +vtkSmartPointer buildLut(const geopro::core::ColorScale& cs, double vmin, double vmax, + int n = 256, bool transparentBelowRange = false); } // namespace geopro::render diff --git a/src/render/actors/VoxelActor.cpp b/src/render/actors/VoxelActor.cpp index 8509e56..aeb43d2 100644 --- a/src/render/actors/VoxelActor.cpp +++ b/src/render/actors/VoxelActor.cpp @@ -1,5 +1,6 @@ #include "actors/VoxelActor.hpp" +#include #include #include @@ -8,6 +9,7 @@ #include #include #include +#include #include #include #include @@ -16,6 +18,8 @@ #include #include #include +#include +#include #include namespace geopro::render { @@ -24,30 +28,88 @@ namespace { // 颜色/不透明度传递函数采样级数。 constexpr int kTransferSamples = 64; -// 体绘制最大不透明度([vmin,vmax] 线性 0→kMaxOpacity)。值越大体越实(越不透明); -// 0.15 偏淡 → 0.30 更实仍可看穿内部。再大(0.4~0.6)会更像实心块、遮挡内部结构。 -constexpr double kMaxOpacity = 0.30; + +// 是否支持 GPU 体绘制(光线投射)。默认 true(有独显的常态);无 GPU 机器由 setVolumeGpuSupported(false) +// 设回退。影响:mask 真白化只有 GPU mapper 支持 → 无 GPU 时不建 mask、改用 SmartVolumeMapper(自动 CPU 回退), +// 空值仍靠不透明度传函(哨兵→0)透明,仅交界处少了 mask 的干净边(重现一圈细渗色)。 +bool g_gpuVolumeSupported = true; // NaN/留空格的哨兵:落在 [vmin,vmax] 之外,传递函数把它映射为完全透明。 double sentinel(double vmin) { return vmin - 1.0; } -// double/int16 两版公用的 mapper+property+volume 组装(行为与原 double 版一致)。 +// 二值 mask 体(UCHAR,255=有效、0=空值)。与标量同维同 origin/spacing、同点序 +// (id=(k*ny+j)*nx+i)。空值格 mask=0 → 喂给 GPU ray cast 后被完全跳过:不着色、不参与 +// 三线性插值,对齐 Surfer Blanking 的真白化语义(消除"空白处沿数据边界渗蓝")。 +// 调用方在填标量的同一循环里写 m->SetValue(id, valid?255:0)。 +vtkSmartPointer makeMaskLike(int nx, int ny, int nz, + double ox, double oy, double oz, + double dx, double dy, double dz, + vtkUnsignedCharArray*& outArr) +{ + auto mask = vtkSmartPointer::New(); + mask->SetDimensions(nx, ny, nz); + mask->SetOrigin(ox, oy, oz); + mask->SetSpacing(dx, dy, dz); + vtkNew m; + m->SetName("mask"); + m->SetNumberOfTuples(static_cast(nx) * ny * nz); + mask->GetPointData()->SetScalars(m); + outArr = m; // image 持有引用,循环结束前有效 + return mask; +} + +// double/int16 两版公用的 mapper+property+volume 组装。mask 非空 → 用 GPU ray cast + 二值 mask +// 做真白化(SmartVolumeMapper 不转发 mask,故走 GPU mapper;桌面端恒有 GL 上下文); +// mask 为空 → 保留 SmartVolumeMapper(GPU/CPU 自适应)。 vtkSmartPointer assembleVolume(vtkImageData* img, vtkColorTransferFunction* color, - vtkPiecewiseFunction* opacity) + vtkPiecewiseFunction* opacity, + vtkImageData* mask) { - // SmartVolumeMapper:有 GPU 走 GPU ray cast,否则自动回退 CPU,避免无 GPU 时卡死/失败。 - vtkNew mapper; - mapper->SetInputData(img); - // 全程统一全质量(GPU 足够快, 实测 ~7ms/帧):关掉交互降采样, 避免"停手补高清"那一帧突跳停顿。 - mapper->SetAutoAdjustSampleDistances(0); - mapper->SetInteractiveAdjustSampleDistances(0); + // 采样距离 + 不透明度单位距离用到几何尺度。 + double sp[3]; + img->GetSpacing(sp); + const double minSp = + std::min({std::abs(sp[0]), std::abs(sp[1]), std::abs(sp[2])}); // 最细体素维度 + double bnd[6]; + img->GetBounds(bnd); + const double diag = std::sqrt((bnd[1] - bnd[0]) * (bnd[1] - bnd[0]) + + (bnd[3] - bnd[2]) * (bnd[3] - bnd[2]) + + (bnd[5] - bnd[4]) * (bnd[5] - bnd[4])); // 包围盒对角(最长穿越路径) + + vtkSmartPointer mapper; + if (mask && g_gpuVolumeSupported) { + // 真白化:mask=0 体素被光线投射完全跳过,杜绝空值格沿边界渗蓝。需 GPU 光线投射支持。 + vtkNew gpu; + gpu->SetInputData(img); + gpu->SetMaskInput(mask); + gpu->SetMaskTypeToBinary(); + gpu->SetAutoAdjustSampleDistances(0); // 全程全质量(GPU 直接 mapper 无交互降采样开关) + // 关了自适应必须显式给【细】采样距离,否则用粗默认值 → 看到一层层体素(分层伪影)。 + if (minSp > 0) gpu->SetSampleDistance(static_cast(0.3 * minSp)); + // 抖动:用噪声纹理微扰每条光线的采样起点,消除规则采样面造成的「木纹/分层」伪影(VTK 官方此用途)。 + gpu->SetUseJittering(1); + mapper = gpu; + } else { + // SmartVolumeMapper:有 GPU 走 GPU ray cast,否则自动回退 CPU,避免无 GPU 时卡死/失败。 + vtkNew sm; + sm->SetInputData(img); + // 全程统一全质量(GPU 足够快, 实测 ~7ms/帧):关掉交互降采样, 避免"停手补高清"那一帧突跳停顿。 + sm->SetAutoAdjustSampleDistances(0); + sm->SetInteractiveAdjustSampleDistances(0); + mapper = sm; + } vtkNew prop; prop->SetColor(color); prop->SetScalarOpacity(opacity); prop->SetInterpolationTypeToLinear(); prop->ShadeOff(); + // 不透明度单位距离 = 包围盒对角 × kOpacityUnitFraction:控制沿深度的累积速度,使色阶「不透明度」滑块 + // 有层次。取对角/10:100%(每单位=1.0)→沿体累积到≈实心、10% 很淡。太大(=整条对角)→100% 也偏透; + // 太小(=体素)→ 低不透明度也累积到全不透明。 + constexpr double kOpacityUnitFraction = 0.1; + if (diag > 0) prop->SetScalarOpacityUnitDistance(kOpacityUnitFraction * diag); auto volume = vtkSmartPointer::New(); volume->SetMapper(mapper); @@ -81,14 +143,52 @@ vtkSmartPointer assembleVolumeI16(vtkImageData* img, vtkNew opacity; opacity->AddPoint(static_cast(geopro::core::ScalarVolumeI16::kBlank), 0.0); - opacity->AddPoint(qminD, 0.0); - opacity->AddPoint(qmaxD, kMaxOpacity); + for (int t = 0; t < kTransferSamples; ++t) { + const double qd = qminD + (qmaxD - qminD) * t / (kTransferSamples - 1); + const auto qvLevel = static_cast(std::lround(qd)); + const double phys = q.toPhys(qvLevel); + opacity->AddPoint(qd, cs.colorAt(phys).a / 255.0 * cs.globalOpacity()); + } - return assembleVolume(img, color, opacity); + // 由预建 short 体扫出二值 mask(kBlank→0 跳过)。稠密体(无 kBlank)→ 全 255,等价无 mask。 + int dims[3]; + img->GetDimensions(dims); + vtkUnsignedCharArray* mArr = nullptr; + auto mask = makeMaskLike(dims[0], dims[1], dims[2], img->GetOrigin()[0], img->GetOrigin()[1], + img->GetOrigin()[2], img->GetSpacing()[0], img->GetSpacing()[1], + img->GetSpacing()[2], mArr); + if (auto* sc = vtkShortArray::SafeDownCast(img->GetPointData()->GetScalars())) { + const vtkIdType n = sc->GetNumberOfTuples(); + for (vtkIdType id = 0; id < n; ++id) + mArr->SetValue(id, sc->GetValue(id) == geopro::core::ScalarVolumeI16::kBlank ? 0 : 255); + } + return assembleVolume(img, color, opacity, mask); } } // namespace +void setVolumeGpuSupported(bool ok) { g_gpuVolumeSupported = ok; } + +void updateVolumeColors(vtkVolume* volume, const geopro::core::ColorScale& cs, double vmin, + double vmax) { + if (!volume || !volume->GetProperty()) return; + if (vmin >= vmax) vmax = vmin + 1.0; + const double blank = sentinel(vmin); + // 与 buildVoxel(float 路径) 同口径重建颜色/不透明度传函,原地换到已有 actor 上(不重建 image → + // 切片基底不变、不被关闭)。 + vtkNew color; + vtkNew opacity; + opacity->AddPoint(blank, 0.0); + for (int t = 0; t < kTransferSamples; ++t) { + const double val = vmin + (vmax - vmin) * t / (kTransferSamples - 1); + const auto c = cs.colorAt(val); + color->AddRGBPoint(val, c.r / 255.0, c.g / 255.0, c.b / 255.0); + opacity->AddPoint(val, c.a / 255.0 * cs.globalOpacity()); + } + volume->GetProperty()->SetColor(color); + volume->GetProperty()->SetScalarOpacity(opacity); +} + vtkSmartPointer buildVoxel(const geopro::core::ScalarVolume& vol, const geopro::core::ColorScale& cs, double ox, double oy, double oz, @@ -109,6 +209,10 @@ vtkSmartPointer buildVoxel(const geopro::core::ScalarVolume& vol, // 标量用 float(非 double):OpenGL 无原生 double 体纹理,GPU 体绘制对 double 处理不稳/部分驱动间歇 // 出空(偶发不渲染根因之一),且省一半显存。float 精度对可视化足够。 + // 二值 mask:NaN 空格→0(光线投射跳过,真白化),有值→255。与标量同循环填,免二次扫描。 + vtkUnsignedCharArray* mArr = nullptr; + auto mask = makeMaskLike(nx, ny, nz, ox, oy, oz, dx, dy, dz, mArr); + vtkNew sc; sc->SetName("v"); sc->SetNumberOfTuples(static_cast(nx) * ny * nz); @@ -118,7 +222,9 @@ vtkSmartPointer buildVoxel(const geopro::core::ScalarVolume& vol, for (int i = 0; i < nx; ++i) { const double v = vol.at(i, j, k); const vtkIdType id = (static_cast(k) * ny + j) * nx + i; - sc->SetValue(id, static_cast(std::isnan(v) ? blank : v)); // NaN → 哨兵 + const bool isBlank = std::isnan(v); + sc->SetValue(id, static_cast(isBlank ? blank : v)); // NaN → 哨兵 + mArr->SetValue(id, isBlank ? 0 : 255); } img->GetPointData()->SetScalars(sc); outImage = img; @@ -131,13 +237,17 @@ vtkSmartPointer buildVoxel(const geopro::core::ScalarVolume& vol, color->AddRGBPoint(val, c.r / 255.0, c.g / 255.0, c.b / 255.0); } - // 不透明度传递函数:哨兵 → 0(透明);[vmin,vmax] 线性递增到 kMaxOpacity。 + // 不透明度传递函数:哨兵 → 0(透明);区间内由色阶 alpha 驱动,再乘体密度主控 kMaxOpacity。 + // 体素不透明度 = (色阶 alpha/255) × kMaxOpacity(整体透明度已在配置时乘进 alpha)。 + // alpha=0 → 真透明;alpha=255(无 alpha 色阶默认)→ 维持 kMaxOpacity 的通透手感,不回归。 vtkNew opacity; opacity->AddPoint(blank, 0.0); - opacity->AddPoint(vmin, 0.0); - opacity->AddPoint(vmax, kMaxOpacity); + for (int t = 0; t < kTransferSamples; ++t) { + const double val = vmin + (vmax - vmin) * t / (kTransferSamples - 1); + opacity->AddPoint(val, cs.colorAt(val).a / 255.0 * cs.globalOpacity()); + } - return assembleVolume(img, color, opacity); + return assembleVolume(img, color, opacity, mask); } vtkSmartPointer buildVoxelI16(const geopro::core::ScalarVolumeI16& vol, @@ -158,6 +268,10 @@ vtkSmartPointer buildVoxelI16(const geopro::core::ScalarVolumeI16& vo img->SetOrigin(ox, oy, oz); img->SetSpacing(dx, dy, dz); + // 二值 mask:kBlank 空格→0(真白化跳过),有值→255。与标量同循环填。 + vtkUnsignedCharArray* mArr = nullptr; + auto mask = makeMaskLike(nx, ny, nz, ox, oy, oz, dx, dy, dz, mArr); + vtkNew sc; sc->SetName("v"); sc->SetNumberOfTuples(static_cast(nx) * ny * nz); @@ -169,6 +283,7 @@ vtkSmartPointer buildVoxelI16(const geopro::core::ScalarVolumeI16& vo const std::int16_t qv = vol.at(i, j, k); const vtkIdType id = (static_cast(k) * ny + j) * nx + i; sc->SetValue(id, qv); + mArr->SetValue(id, qv == geopro::core::ScalarVolumeI16::kBlank ? 0 : 255); } img->GetPointData()->SetScalars(sc); outImage = img; @@ -189,13 +304,17 @@ vtkSmartPointer buildVoxelI16(const geopro::core::ScalarVolumeI16& vo color->AddRGBPoint(qd, c.r / 255.0, c.g / 255.0, c.b / 255.0); } - // 不透明度传递函数(量化域):kBlank → 0(透明);[qmin,qmax] 线性递增到 kMaxOpacity。 + // 不透明度传递函数(量化域):kBlank → 0(透明);区间内由色阶 alpha 驱动 × 体密度主控 kMaxOpacity。 vtkNew opacity; opacity->AddPoint(static_cast(geopro::core::ScalarVolumeI16::kBlank), 0.0); - opacity->AddPoint(qminD, 0.0); - opacity->AddPoint(qmaxD, kMaxOpacity); + for (int t = 0; t < kTransferSamples; ++t) { + const double qd = qminD + (qmaxD - qminD) * t / (kTransferSamples - 1); + const auto qvLevel = static_cast(std::lround(qd)); + const double phys = q.toPhys(qvLevel); + opacity->AddPoint(qd, cs.colorAt(phys).a / 255.0 * cs.globalOpacity()); + } - return assembleVolume(img, color, opacity); + return assembleVolume(img, color, opacity, mask); } vtkSmartPointer buildVoxel(const geopro::core::ScalarVolume& vol, diff --git a/src/render/actors/VoxelActor.hpp b/src/render/actors/VoxelActor.hpp index 92b94bd..5aa9111 100644 --- a/src/render/actors/VoxelActor.hpp +++ b/src/render/actors/VoxelActor.hpp @@ -8,6 +8,15 @@ #include "model/ScalarVolumeI16.hpp" namespace geopro::render { +// 设置是否支持 GPU 体绘制(启动探测后调一次)。false → 体绘制回退 SmartVolumeMapper(CPU 自适应)、 +// 不建 mask(空值仍透明,仅边缘少了 mask 的干净度)。默认 true。 +void setVolumeGpuSupported(bool ok); + +// 原地更新已渲染体的颜色/不透明度(仅换传函,不重建 image):色阶改动时用,避免重建 image 把切片基底 +// 换掉、连带关闭未保存切片。float 体路径口径(标准 addVolume 产物)。 +void updateVolumeColors(vtkVolume* volume, const geopro::core::ColorScale& cs, double vmin, + double vmax); + // 体上抽等值面(marching cubes/FlyingEdges)→ 不透明实心 actor,凸显超阈异常体(参考图红块)。 // img 为 buildVoxel 暴露的 vtkImageData(标量=物理值,留空=哨兵 vmin-1,低于任意 isoValue 不成面)。 // isoValue 在 [vmin,vmax] 内;颜色取 ColorScale 在 isoValue 处的实色、不透明。无超阈区 → 返回 nullptr。 diff --git a/src/render/interact/InteractionManager.cpp b/src/render/interact/InteractionManager.cpp index 33baf68..0fd34d1 100644 --- a/src/render/interact/InteractionManager.cpp +++ b/src/render/interact/InteractionManager.cpp @@ -29,6 +29,15 @@ std::array imageBounds(vtkImageData* img) { if (img) img->GetBounds(b.data()); return b; } +// 据三维体总不透明度(0~1)把切片不透明度模式解析为具体总不透明度(0~1)。 +double resolveSliceOpacity(SliceOpacityMode mode, double volumeOpacity01) { + switch (mode) { + case SliceOpacityMode::Full: return 1.0; // 100% 不透明 + case SliceOpacityMode::VolumePlus50: return std::min(1.0, volumeOpacity01 + 0.5); + case SliceOpacityMode::FollowVolume: return volumeOpacity01; + } + return 1.0; +} } // namespace InteractionManager::InteractionManager(vtkRenderWindowInteractor* interactor, @@ -110,8 +119,19 @@ void InteractionManager::setVolumeImage(const std::string& volumeDsId, vtkImageD const geopro::core::ColorScale& cs, double vmin, double vmax) { if (volumeDsId.empty()) return; auto it = volumes_.find(volumeDsId); - // 同体 image 变更(重建/改色阶):旧 image 即将失效 → 先关该体已显示切片(上层 syncSlices 用新 image 重现)。 - if (it != volumes_.end() && it->second.image != image) closeSlicesOfVolume(volumeDsId); + if (it != volumes_.end() && it->second.image != image) { + // image 变(体重建):旧 image 即将失效 → 关该体切片(上层 syncSlices 用新 image 重现已保存切片)。 + closeSlicesOfVolume(volumeDsId); + } else if (it != volumes_.end()) { + // image 不变、仅色阶变(原地改色):该体下【未保存】切片跟随改色(颜色 + 按模式重解析总不透明度); + // 已保存切片用自己的色阶、不动。切片不被关闭,解决"改体色阶刷掉未保存切片"。 + for (const auto& s : slices_) { + if (s->volumeDsId() != volumeDsId || !s->dsId().empty()) continue; + s->setColorScale(cs); + s->setOpacity(s->opacityMode(), resolveSliceOpacity(s->opacityMode(), cs.globalOpacity())); + } + safeRender(); + } volumes_[volumeDsId] = VolumeImg{image, cs, vmin, vmax}; } @@ -321,6 +341,53 @@ void InteractionManager::tagSelectedSlice(const std::string& dsId) { slices_[static_cast(selected_)]->setInteractive(false); // 保存即定稿锁定(不可改) } +void InteractionManager::setSelectedSliceOpacity(SliceOpacityMode mode) { + if (selected_ < 0 || selected_ >= static_cast(slices_.size())) return; + SliceTool* s = slices_[static_cast(selected_)].get(); + const VolumeImg* v = volumeOf(s->volumeDsId()); + const double volOp = v ? v->cs.globalOpacity() : 1.0; // 三维体的总不透明度(参照量) + s->setOpacity(mode, resolveSliceOpacity(mode, volOp)); + safeRender(); +} + +SliceOpacityMode InteractionManager::selectedSliceOpacityMode() const { + if (selected_ < 0 || selected_ >= static_cast(slices_.size())) return SliceOpacityMode::Full; + return slices_[static_cast(selected_)]->opacityMode(); +} + +double InteractionManager::selectedSliceOpacity() const { + if (selected_ < 0 || selected_ >= static_cast(slices_.size())) return 1.0; + return slices_[static_cast(selected_)]->opacity(); +} + +geopro::core::ColorScale InteractionManager::selectedSliceColorScaleSnapshot() const { + if (selected_ < 0 || selected_ >= static_cast(slices_.size())) return {}; + const SliceTool* s = slices_[static_cast(selected_)].get(); + geopro::core::ColorScale cs = s->colorScale(); // 颜色快照(来自三维体) + cs.setGlobalOpacity(s->opacity()); // 当前总不透明度并入 → 切片自己的色阶对象 + return cs; +} + +void InteractionManager::setSelectedSliceColorScale(const geopro::core::ColorScale& cs) { + if (selected_ < 0 || selected_ >= static_cast(slices_.size())) return; + SliceTool* s = slices_[static_cast(selected_)].get(); + s->setColorScale(cs); + s->setOpacity(SliceOpacityMode::Full, cs.globalOpacity()); // 已保存切片:不透明度取自其色阶 + safeRender(); +} + +bool InteractionManager::setSliceColorScaleByDsId(const std::string& dsId, + const geopro::core::ColorScale& cs) { + for (const auto& s : slices_) { + if (s->dsId() != dsId) continue; + s->setColorScale(cs); + s->setOpacity(SliceOpacityMode::Full, cs.globalOpacity()); // 不透明度并入其色阶 globalOpacity + safeRender(); + return true; + } + return false; +} + vtkImageData* InteractionManager::selectedSliceImage() const { if (selected_ < 0 || selected_ >= static_cast(slices_.size())) return nullptr; return slices_[static_cast(selected_)]->reslicedOutput(); diff --git a/src/render/interact/InteractionManager.hpp b/src/render/interact/InteractionManager.hpp index af14f27..244b64a 100644 --- a/src/render/interact/InteractionManager.hpp +++ b/src/render/interact/InteractionManager.hpp @@ -92,6 +92,20 @@ public: std::string selectedSliceVolumeDsId() const; // 给当前选中(未保存)切片打 dsId 标签:保存=把当前切片链接到新数据集(不重绘、不重复)。 void tagSelectedSlice(const std::string& dsId); + + // ── 切片不透明度(与三维体解耦;总不透明度据所属体不透明度解析)────────────── + // 设置选中切片不透明度模式。无选中则忽略。 + void setSelectedSliceOpacity(SliceOpacityMode mode); + // 选中切片当前不透明度模式(菜单勾选当前项用)。无选中返回 Full。 + SliceOpacityMode selectedSliceOpacityMode() const; + // 选中切片当前解析后的总不透明度(0~1)(保存切片取具体值用)。无选中返回 1。 + double selectedSliceOpacity() const; + // 选中切片色阶快照(颜色 + 当前总不透明度并入 globalOpacity):保存切片建自己色阶对象用。 + geopro::core::ColorScale selectedSliceColorScaleSnapshot() const; + // 用给定色阶覆盖选中切片(已保存切片编辑自己色阶时用;不透明度取该色阶 globalOpacity)。 + void setSelectedSliceColorScale(const geopro::core::ColorScale& cs); + // 用给定色阶覆盖指定 dsId 的已显示切片(还原/编辑已保存切片色阶时用,不依赖选中)。找到返回 true。 + bool setSliceColorScaleByDsId(const std::string& dsId, const geopro::core::ColorScale& cs); // 选中切片的重采样 2D 标量影像(导出 dat 用);无选中返回 nullptr。 vtkImageData* selectedSliceImage() const; // 选中切片"上色后"的 2D 影像(导出图片用):重采样标量经切片色阶 LUT → RGB 图;无选中返回 nullptr。 diff --git a/src/render/interact/SliceTool.cpp b/src/render/interact/SliceTool.cpp index 054feab..b72d46e 100644 --- a/src/render/interact/SliceTool.cpp +++ b/src/render/interact/SliceTool.cpp @@ -34,9 +34,37 @@ void SliceTool::initWidget(const geopro::core::ColorScale& cs, double vmin, doub widget_->TextureInterpolateOn(); widget_->DisplayTextOff(); - // 色阶 LUT 套用:用户自管 LUT(不让 widget 用默认灰度窗位)。 - auto lut = buildLut(cs, vmin, vmax); + // 色阶/区间存为单一真源;总不透明度(opacity_)默认 100%。LUT 由 rebuildLut 统一建。 + cs_ = cs; + vmin_ = vmin; + vmax_ = vmax; + rebuildLut(); +} + +void SliceTool::rebuildLut() { + if (!widget_) return; + double vmin = vmin_, vmax = vmax_; + if (vmin >= vmax) vmax = vmin + 1.0; + // 切片渲染单一真源:颜色/单色 alpha 取自 cs_,总不透明度由 opacity_ 覆盖(与三维体解耦)。 + geopro::core::ColorScale c = cs_; + c.setGlobalOpacity(opacity_); + // transparentBelowRange=true:体的留空哨兵(vmin-1.0)在切片上映射为透明,白化区不再填蓝。 + auto lut = buildLut(c, vmin, vmax, 256, /*transparentBelowRange=*/true); widget_->SetLookupTable(lut); + // 关键:钉死 window/level 到 [vmin,vmax]。否则 vtkImagePlaneWidget 会按【输入标量范围】 + // (含哨兵)自动拉伸映射,把哨兵顶到最低色格填蓝(实测 tests/spike/slice_alpha_probe 确诊)。 + widget_->SetWindowLevel(vmax - vmin, 0.5 * (vmin + vmax)); +} + +void SliceTool::setOpacity(SliceOpacityMode mode, double resolved01) { + opacityMode_ = mode; + opacity_ = resolved01 < 0.0 ? 0.0 : (resolved01 > 1.0 ? 1.0 : resolved01); + rebuildLut(); +} + +void SliceTool::setColorScale(const geopro::core::ColorScale& cs) { + cs_ = cs; + rebuildLut(); } void SliceTool::applyMarginsAndActivate() { diff --git a/src/render/interact/SliceTool.hpp b/src/render/interact/SliceTool.hpp index c043500..be5d3e5 100644 --- a/src/render/interact/SliceTool.hpp +++ b/src/render/interact/SliceTool.hpp @@ -16,6 +16,10 @@ class vtkTrivialProducer; namespace geopro::render::interact { +// 切片不透明度模式(用户右键设置):满(100%)/ 三维体+50% / 跟随三维体。 +// 实际渲染用的"总不透明度"= 据所属三维体的不透明度解析(见 InteractionManager::resolveSliceOpacity)。 +enum class SliceOpacityMode { Full, VolumePlus50, FollowVolume }; + // 单个切片工具:封装 vtkImagePlaneWidget。 // 内部对体素 vtkImageData 做 reslice + 纹理着色(spec §9.1 钉死 reslice 路线,非 cutter)。 // 轴向(UpDown/FrontBack/LeftRight):SetPlaneOrientationToX/Y/Z,角度固定。 @@ -88,6 +92,17 @@ public: // 切回零重建。重显时复原锁定态(SetEnabled 可能把交互重置为开)。 void setVisible(bool on); + // ── 不透明度(切片独立,与三维体解耦)────────────────────────────────── + // 设置不透明度模式 + 已解析的总不透明度(0~1)。颜色映射/单色 alpha 仍由色阶(cs_)给, + // 这里只决定整条切片的"总不透明度"(= cs_.globalOpacity 在切片渲染时被它覆盖)。 + void setOpacity(SliceOpacityMode mode, double resolved01); + SliceOpacityMode opacityMode() const { return opacityMode_; } + double opacity() const { return opacity_; } // 已解析的总不透明度(0~1) + + // 切换本切片色阶(颜色):未保存切片跟随三维体改色、已保存切片用自己的色阶。重建 LUT。 + void setColorScale(const geopro::core::ColorScale& cs); + const geopro::core::ColorScale& colorScale() const { return cs_; } + // 关闭:Off() 并解除 interactor 绑定(幂等)。 void close(); @@ -104,7 +119,15 @@ private: void initWidget(const geopro::core::ColorScale& cs, double vmin, double vmax); // 共享 widget 配置 void applyMarginsAndActivate(); // 按 axis 设旋转锁 + On() + 装交互观察者 + void rebuildLut(); // 据 cs_ + opacity_ 重建 LUT 并钉死 window/level std::array imageBounds() const; + + // 色阶(颜色) + 区间 + 总不透明度:切片渲染单一真源(cs_ 提供颜色/单色 alpha,opacity_ 覆盖总不透明度)。 + geopro::core::ColorScale cs_; + double vmin_ = 0.0; + double vmax_ = 1.0; + double opacity_ = 1.0; // 已解析的总不透明度(0~1),默认 100% + SliceOpacityMode opacityMode_ = SliceOpacityMode::Full; }; } // namespace geopro::render::interact diff --git a/tests/spike/CMakeLists.txt b/tests/spike/CMakeLists.txt index 9e1956d..56cfa7e 100644 --- a/tests/spike/CMakeLists.txt +++ b/tests/spike/CMakeLists.txt @@ -6,6 +6,9 @@ find_package(VTK REQUIRED COMPONENTS CommonColor FiltersGeometry FiltersModeling + FiltersSources + ImagingCore + InteractionWidgets RenderingOpenGL2 IOImage ) @@ -19,3 +22,9 @@ add_executable(render_verify render_verify.cpp) target_link_libraries(render_verify PRIVATE geopro_render geopro_data geopro_core ${VTK_LIBRARIES}) vtk_module_autoinit(TARGETS render_verify MODULES ${VTK_LIBRARIES}) + +# 实测:vtkImagePlaneWidget 纹理是否支持 texel alpha 透明(白化切片可行性)。 +add_executable(slice_alpha_probe slice_alpha_probe.cpp) +target_link_libraries(slice_alpha_probe PRIVATE + geopro_render geopro_core ${VTK_LIBRARIES}) +vtk_module_autoinit(TARGETS slice_alpha_probe MODULES ${VTK_LIBRARIES}) diff --git a/tests/spike/slice_alpha_probe.cpp b/tests/spike/slice_alpha_probe.cpp new file mode 100644 index 0000000..54d8df5 --- /dev/null +++ b/tests/spike/slice_alpha_probe.cpp @@ -0,0 +1,161 @@ +// 实测:vtkImagePlaneWidget 的纹理平面能否把 LUT 下溢透明(alpha=0)的 texel 渲染成透明。 +// 构造一张图:左半真值(映射红)、右半哨兵 -1(下溢→透明)。纹理平面背后放一块不透明【黄】板, +// 背景【绿】。套用 buildLut(transparentBelowRange=true) 后离屏渲染、回读像素分类: +// 右半空白处出现【黄】 → texel alpha 透明【成功】(黄板透过来) +// 出现【蓝】 → 值被钳到最低色(透明没生效) +// 出现【黑】 → alpha 被丢 +// 纯实测,不靠文档措辞猜。 +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "ColorLutBuilder.hpp" +#include "model/ColorScale.hpp" + +int main() { + using namespace geopro; + + // 1) 构造体: 40x40x3, 左半 i<20 → 200(真值,映射红); 右半 → -1(哨兵,下溢透明)。 + const int nx = 40, ny = 40, nz = 3; + vtkNew img; + img->SetDimensions(nx, ny, nz); + img->SetOrigin(0, 0, 0); + img->SetSpacing(1, 1, 1); + vtkNew sc; + sc->SetName("v"); + sc->SetNumberOfTuples(static_cast(nx) * ny * nz); + // 全值域核查:左侧 [0..nx-9] 列填 0→200 渐变(真数据),右侧 8 列填 -1(哨兵/白化)。 + // 验证 (a) 颜色映射跨整个 [vmin,vmax] 是否正确;(b) 空值是否透明。 + const int blankCols = 8; + const int gradCols = nx - blankCols; + for (int k = 0; k < nz; ++k) + for (int j = 0; j < ny; ++j) + for (int i = 0; i < nx; ++i) { + const vtkIdType id = (static_cast(k) * ny + j) * nx + i; + const float v = (i < gradCols) + ? static_cast(200.0 * i / (gradCols - 1)) // 0→200 渐变 + : -1.0F; // 哨兵 + sc->SetValue(id, v); + } + img->GetPointData()->SetScalars(sc); + + // 2) 三色阶(全不透明): 0→蓝, 100→黄, 200→红。渐变应呈 蓝→黄→红;-1 应透明(露品红背板)。 + core::ColorScale cs; + cs.addStop(0.0, core::Rgba{0, 0, 255, 255}); + cs.addStop(100.0, core::Rgba{255, 255, 0, 255}); + cs.addStop(200.0, core::Rgba{255, 0, 0, 255}); + auto lut = render::buildLut(cs, 0.0, 200.0, 256, /*transparentBelowRange=*/true); + + // 3) 渲染器 + 离屏窗口 + 交互器(widget 必需)。背景绿。 + vtkNew ren; + ren->SetBackground(0.15, 0.15, 0.15); // 深灰 = 背景 + vtkNew rw; + rw->SetOffScreenRendering(1); + rw->AddRenderer(ren); + rw->SetSize(400, 400); + vtkNew iren; + iren->SetRenderWindow(rw); + + // 4) 纹理平面【背后】放一块不透明黄板(z=0.5, 在切片 z=1 之下=远离上方相机)。 + vtkNew yp; + yp->SetOrigin(0, 0, 0.5); + yp->SetPoint1(nx - 1, 0, 0.5); + yp->SetPoint2(0, ny - 1, 0.5); + vtkNew ym; + ym->SetInputConnection(yp->GetOutputPort()); + vtkNew ya; + ya->SetMapper(ym); + ya->GetProperty()->SetColor(1.0, 0.0, 1.0); // 品红 = 背板(空值透明处会露出它) + ya->GetProperty()->LightingOff(); + ren->AddActor(ya); + + // 5) 真 vtkImagePlaneWidget: Z 法向切片 1(z=1), 套用户 LUT, 最近邻避免边界混色。 + vtkNew prod; + prod->SetOutput(img); + vtkNew w; + w->SetInteractor(iren); + w->SetInputConnection(prod->GetOutputPort()); + w->SetPlaneOrientationToZAxes(); + w->SetSliceIndex(1); + w->SetResliceInterpolateToNearestNeighbour(); + w->TextureInterpolateOff(); + w->SetLookupTable(lut); + // 钉死 window/level 到 [vmin,vmax]=[0,200],阻止 widget 按输入标量范围自动拉伸。 + w->SetWindowLevel(200.0, 100.0); + w->On(); + + // 6) 顶视相机(沿 -Z 俯视), 平行投影框住平面。 + ren->GetActiveCamera()->SetPosition(nx / 2.0, ny / 2.0, 100.0); + ren->GetActiveCamera()->SetFocalPoint(nx / 2.0, ny / 2.0, 1.0); + ren->GetActiveCamera()->SetViewUp(0, 1, 0); + ren->GetActiveCamera()->ParallelProjectionOn(); + ren->ResetCamera(); + rw->Render(); + + // 7) 回读像素, 分类计数。 + vtkNew w2i; + w2i->SetInput(rw); + w2i->Update(); + vtkImageData* out = w2i->GetOutput(); + { + vtkNew pw; + pw->SetFileName("D:/dev/spike_data/slice_alpha_probe.png"); + pw->SetInputConnection(w2i->GetOutputPort()); + pw->Write(); + } + int dims[3]; + out->GetDimensions(dims); + long red = 0, yellow = 0, green = 0, blue = 0, black = 0, other = 0; + for (int y = 0; y < dims[1]; ++y) + for (int x = 0; x < dims[0]; ++x) { + const auto* p = static_cast(out->GetScalarPointer(x, y, 0)); + const int r = p[0], g = p[1], b = p[2]; + if (r > 150 && g < 100 && b < 100) ++red; + else if (r > 150 && g > 150 && b < 100) ++yellow; + else if (r < 100 && g > 150 && b < 100) ++green; + else if (b > 150 && r < 100 && g < 100) ++blue; + else if (r < 60 && g < 60 && b < 60) ++black; + else ++other; + } + + std::printf("PIXELS red(data)=%ld yellow(behind-shows=TRANSPARENT)=%ld " + "green(bg)=%ld blue(clamp-lowest)=%ld black(alpha-dropped)=%ld other=%ld\n", + red, yellow, green, blue, black, other); + + // 8) 直接判读 colormap 对下溢值输出的 RGBA(隔离 LUT 正确性 vs 纹理渲染)。 + if (auto* cm = w->GetColorMap()) { + cm->Update(); + if (auto* co = cm->GetOutput()) { + int cd[3]; + co->GetDimensions(cd); + const int comps = co->GetNumberOfScalarComponents(); + // 右半某点(i=30,j=20,值=150) colormap 应产出 alpha=0。 + const auto* px = static_cast(co->GetScalarPointer(30, 20, 0)); + std::printf("COLORMAP comps=%d rightHalfTexel.RGBA=", comps); + for (int c = 0; c < comps; ++c) std::printf("%d ", px[c]); + std::printf("(期望 alpha=0 => LUT 正确产出透明)\n"); + } + } + + std::printf("VERDICT: 若 yellow 远多于 blue/black => widget 纹理【支持】texel alpha 透明; " + "若 blue 多 => 钳最低色; 若 black 多 => 丢 alpha\n"); + return 0; +}