feat(vtk): 异常圈定+保存闭环(#4b)+多项交互修复

#4b 异常圈定→保存→渲染→持久 闭环(异常挂三维体,mock 持久化):
- AnomalyDrawTool:切片平面上圈定多边形(屏幕射线-平面求交落点);左键加点(醒目黄圆点)、
  鼠标移动虚线橡皮筋(末点→光标跟手)、双击/右键/回车闭合、Esc 取消、左上屏幕提示;高优先级观察者绘制期独占输入
- AnomalySaveDialog:名称/异常类型(mock)/备注 + 截图预览及尺寸(R50)
- 切片右键「创建异常」接通:圈定→草稿渲染→截图→保存对话框→saveAnomaly(挂三维体)→红色多边形渲染
- onVolumeChanged→reloadAnomalies:体到场重载其异常(= 显示过滤"随数据集"默认档)
- SliceExport 加 captureRenderWindowPng(异常截图,带尺寸);Anomaly 补 exceptionTypeId/remark

交互修复(用户实测确认):
- 生成三维体**按勾选集合**(checkbox)收集源,不再受行高亮/右键项影响(撤销错误的多选子类)
- buildVolume 网格**覆盖全程**:包络过大时放大间距(fitAxis)而非截断 → 跨 TM 多剖面不再丢远端剖面
- 滚轮回退为"推进当前选中切片";点击切片外**取消选中**(取消后滚轮恢复相机缩放)
- 修圈定闭合误触切片:闭合事件提前 abort,避免漏给切片 widget 触发 slice-motion

文档:plans/2026-06-18-vtk-3d-anomaly.md §6 摆放决策(3D异常控制在三维分析区/右侧总表为全集)+§7/§8 状态;
  design spec 顶部更正块(异常挂三维体/remarkSourceType=标注形态/无截图字段/独立显隐);HANDOFF 更新到 4a/4b。

编译链接绿(build.bat app exit 0);4b 闭环 + 交互修复已用户实测通过。下一步 4c:三维分析区 3D 异常控制(树+过滤R86-87+VTK选中联动R84+显隐+删除)。
This commit is contained in:
gaozheng 2026-06-18 18:31:46 +08:00
parent 4e1b8e7635
commit 6210d615f3
18 changed files with 663 additions and 68 deletions

View File

@ -58,7 +58,10 @@
- **已保存切片重渲染**:分析栏勾选→`syncSlices`在当前活动体上还原(`showSavedSlice`),取消→移除;靠`onVolumeChanged→syncSlices`解决父体异步到场。dd_slice 不进控制器(避免 loadSection 失败)main 编排走 InteractionManager。
- **场景↔列表同步**VTK「关闭」已保存切片→`onSliceClosed`→列表取消勾选。`Column3DAnalysis::setDatasets`按 dsId 保留勾选+仅勾选集变化才发信号(修"保存切片连带取消体勾选/列表重置")。
- 导出:`SliceExport.{hpp,cpp}`(图片=切片上采样2048上色 PNGdat=重采样标量网格)。切片持久化=`Api3dRepository` createSlice/saveSlice/deleteSlice 内存 mock + sliceRows/isSliceDataset/sliceSpec。
4. 异常:切片右键创建异常(圈定+保存对话框含截图)→ **接真实端点**。VTK 切片右键菜单的「创建异常」入口已占位(弹"开发中")。**下一主线**。
4. 异常(**进行中**,全量含异常体/列表/过滤,计划见 `plans/2026-06-18-vtk-3d-anomaly.md`)。**异常挂三维体**(非切片非源ds见记忆 vtk-3d-persistence-structure)mock 持久化(三维体/切片端点未就绪)。
- **4a ✅ 已提交(4e1b8e7)**`core::Anomaly` 补 3D(id/volumeDsId/consortiumId/worldPts/plane)`buildAnomaly3D``I3dSceneView`+`VtkSceneView` addAnomaly/removeAnomaly/clearAnomalies/setAnomalyVisible(按id跟踪actor)`Api3dRepository` 异常 mock(saveAnomaly/loadAnomalyTree按volumeDsId+consortiumId分组/delete)。**附带修复测试漂移→228/228 绿**。地基、尚不可见。
- **4b 进行中**:圈定工具(切片平面画多边形)+保存对话框(名称/类型/备注/截图)+切片右键「创建异常」接通 → 画→存→显示→删闭环。
- **4c 待做**:异常/异常体列表(R69-88,扩展 ObjectExceptionPanel)+选中双向联动+显示过滤+删除分组+属性/截图。
5. 分析栏右键接线:**已完成**(切片 保存/保存为/导出▸/删除 全接;体 切片▸/详情)`colorScaleRequested` 仍占位("色阶开发中")。已移除"显示/隐藏"(勾选即显隐)。
6. 三维体/切片/异常详情面板(源数据/插值参数/色阶/测量点数体积/异常列表)。
- **其它小欠项**:三维分析栏完整三级树"对象→三维体→切片"里"对象"根层未套(体目前是顶层);真实色阶编辑。

View File

@ -63,27 +63,34 @@
- 异常体(consortium)分组mock 内存(`map<bodyId, {name,typeName,memberIds}>`);真实端点 `exceptionConsortium/*` 后续接。
- 接口签名不变;后端整链就绪仅换实现。
## 6. 列表面板R69-88+ 选中联动 + 过滤
## 6. 异常展示与控制的摆放(用户 2026-06-18 定,需求实证 R28/R36/R58-88
扩展 `ObjectExceptionPanel`(或在三维分析视图侧新建异常面板,复用其树构建):
- 树:对象 → 异常体 → 异常 + 未分组异常(R71-77)。
- 勾选(显隐)、单选(选中) → 信号;选中 ↔ VTK 视图异常高亮**双向联动**(R84)。
- 操作(R79):删除异常、删除分组(deleteAnomaly/deleteAnomalyGroup)。
- 异常属性(R83):选中异常 → 详情(名称/类型/坐标/截图/备注)。
- 显示过滤(R86-87):全部显示 / 随GS / 随数据集 / 全部隐藏 → 控制 VTK 异常可见性集合。
- 异常属性·截图(R88):展示截图缩略 + "确定截图大小"。
需求结构R58/R67/R69/R90 均为 C1 顶级分节 = **数据详情栏**的各类详情内容R28/R36「数据详情 → 在数据详情栏显示」。R84 选中联动、R86-87 VTK 显示过滤 = **3D 场景操作**。结论(职责拆分,互补不重复)
- **三维分析区 = 3D 异常的"场景控制"**(本期 4c 重点3D 异常现为 mock
- 树:对象 → 三维体 → 异常异常挂三维体R61非切片非源 ds见记忆 `vtk-3d-persistence-structure`)。
- **显示过滤 4 档(R86-87)**:全部显示 / 随GS / 随数据集 / 全部隐藏 —— **独立于体勾选**控制 VTK 异常可见性(解决"异常被体勾选绑死")。
- 每条异常**单独显隐**(复用 AnomalyListPanel 的"眼睛")。
- **VTK 选中双向联动(R84)**:列表选中 ↔ VTK 高亮。
- 删除异常 / 删除分组(R79-81, deleteAnomaly/deleteAnomalyGroup)。
- **右侧「对象异常」面板(现有 `ObjectExceptionPanel`) = 异常全集 master**:对象下所有异常总表。**本期保持不动**(仍连后端 2D 异常);后端三维体/切片/异常整链就绪后3D 异常并入此处成全集。
- **三维体数据详情(R58-65)**:源数据/切片/**异常列表(R61,只读摘要)**/插值参数/色阶/测量——经右键「数据详情」打开。
> 不在右侧总表里塞 3D 场景控制(过滤/联动属 3D 操作,归三维分析区);不在三维分析区重复全集总表。
## 7. main.cpp 编排
- 切片右键「创建异常」→ 启动 `AnomalyDrawTool`(用当前选中切片平面) → 圈定完成 → `AnomalySaveDialog``scene3dRepo->saveAnomaly` → 渲染(view addAnomaly) + 刷新异常面板。
- 当前对象/三维体变化 → `loadAnomalyTree` → 填异常面板 + 渲染已存异常。
- 面板选中/勾选/过滤/删除 → 驱动 view 的 setAnomalyVisible/Selected + 仓储删VTK 点选异常 → 面板选中(联动)。
- 切片右键「创建异常」→ 启动 `AnomalyDrawTool`(当前选中切片平面) → 圈定 → `AnomalySaveDialog``saveAnomaly` → 渲染(addAnomaly) + 刷新三维分析区异常列表。**[4b 已实现]**
- 体到场/移除(onVolumeChanged) → `loadAnomalyTree(volumeId)` → 渲染该体已存异常(reloadAnomalies)。**[4b 已实现,= "随数据集" 档默认]**
- 三维分析区异常列表:选中/显隐/过滤/删除 → 驱动 view 的 setAnomalyVisible/Selected + 仓储删VTK 点选异常 → 列表选中(联动)。**[4c]**
## 8. 阶段(每阶段编译绿 + 用户实测)
- **4a 基础**§1 模型 + §2 渲染/接口 + §5 mock 持久化(saveAnomaly/loadAnomalyTree/delete) + main 加载已存异常渲染。可注入一两个测试异常验证 3D 渲染。无圈定/对话框。
- **4b 圈定+保存**§3 圈定工具 + §4 保存对话框(含截图) + 切片右键「创建异常」接通 → 闭环:画→存→显示→删。
- **4c 列表/异常体/联动/过滤**§6 面板交互(选中联动/过滤/删除分组) + 异常体分组 + 异常属性/截图展示。
- **4a 基础 ✅ 已提交(4e1b8e7)**§1 模型 + §2 渲染/接口 + §5 mock 持久化 + 测试修复(228/228 绿)。
- **4b 圈定+保存 ✅ 已实现(未提交,用户已测通)**§3 `AnomalyDrawTool`(切片平面圈定,射线-平面求交,左键加点/双击·右键·回车闭合/Esc 取消/屏幕提示) + §4 `AnomalySaveDialog`(名称/类型 mock/备注/截图预览) + 切片右键「创建异常」接通 + onVolumeChanged→reloadAnomalies(随体重载渲染)。闭环:画→存→显示→跨重勾持久。
- 同批交互修复(待提交):生成体**按勾选集合**(非行高亮/右键项)、buildVolume 网格**覆盖全程**(跨 TM 多剖面不截断)、滚轮推进选中切片(点切片外取消选中→恢复缩放)。
- **4c 三维分析区 3D 异常控制(下一步)**§6 —— 三维分析区异常树(对象→三维体→异常) + **显示过滤 4 档(R86-87)** + **VTK 选中双向联动(R84)** + 每条显隐 + 删除/删组 + 异常属性(R83)。异常体分组 mock。右侧总表不动。
- **后续**:三维体/切片数据详情(R58-65/R67);真实端点整链就绪后切真实(异常并入右侧全集)。
## 9. 风险/待确认

View File

@ -5,6 +5,13 @@
- 依据①《Geopro3.0 需求表.xlsx》「补充需求」页行号见引用② 与产品方就 6 个设计问题的确认;③ 现有代码。
- 原则:缺后端端点的**先本地 mock**(保证功能可见可用),端点就绪后切真实;能纯客户端做的先做。
> **⚠ 更正2026-06-18本文档以下异常部分已被修订以此为准**——实现计划见 `plans/2026-06-18-vtk-3d-anomaly.md`,结构铁律见记忆 `vtk-3d-persistence-structure`
> 1. **异常挂「三维体」**`remarkSourceId` = 三维体 ds id**不挂切片**§1.3 的 `parentSliceId` 作废)——切片是临时圈定载体,业务语义上异常属于三维体(需求 R61
> 2. **`remarkSourceType` = 标注形态**1点/2线/3面/4文字**不是**"来源实体类型"§3 原表述更正,实证 `commercial-admin/contourPage.vue:386`)。接口不限定挂载实体类型,`remarkSourceId` 放谁 id 挂谁。
> 3. 异常请求体**无截图字段**;补充需求 **R88「增加截图属性」**证实截图是待新增属性 → 现 mock 本地存。
> 4. **摆放**3D 异常的"场景控制"(树+显示过滤 R86-87+VTK 选中联动 R84+显隐+删除)放**三维分析区**;右侧「对象异常」面板 = 异常全集 master暂连后端 2D整链就绪后并入 3D
> 5. 异常**独立显隐**靠 R86-87 过滤(全部显示/随GS/随数据集/全部隐藏),**不被三维体勾选绑死**。
---
## 1. 核心数据模型

View File

@ -0,0 +1,72 @@
#include "AnomalySaveDialog.hpp"
#include <QComboBox>
#include <QDialogButtonBox>
#include <QFormLayout>
#include <QLabel>
#include <QLineEdit>
#include <QPixmap>
#include <QPlainTextEdit>
#include <QVBoxLayout>
namespace geopro::app {
namespace {
// 异常类型 mock 列表label, id。真实 exceptionType 端点只读、后续接。
struct TypeItem { const char* label; const char* id; };
const TypeItem kMockTypes[] = {
{"断层", "mock-fault"},
{"破碎带", "mock-fracture"},
{"含水构造", "mock-water"},
{"其它", "mock-other"},
};
} // namespace
AnomalySaveDialog::AnomalySaveDialog(const QString& screenshotPath, int shotW, int shotH,
QWidget* parent)
: QDialog(parent) {
setWindowTitle(QStringLiteral("保存异常"));
setModal(true);
auto* root = new QVBoxLayout(this);
auto* form = new QFormLayout();
name_ = new QLineEdit(QStringLiteral("异常"));
form->addRow(QStringLiteral("名称"), name_);
type_ = new QComboBox();
for (const auto& t : kMockTypes)
type_->addItem(QString::fromUtf8(t.label), QString::fromUtf8(t.id));
form->addRow(QStringLiteral("异常类型"), type_);
remark_ = new QPlainTextEdit();
remark_->setFixedHeight(60);
form->addRow(QStringLiteral("备注"), remark_);
root->addLayout(form);
// 截图预览 + 大小R50「确定截图大小」
if (!screenshotPath.isEmpty()) {
root->addWidget(new QLabel(QStringLiteral("截图(%1 × %2").arg(shotW).arg(shotH)));
QPixmap pm(screenshotPath);
if (!pm.isNull()) {
auto* img = new QLabel();
img->setPixmap(pm.scaledToWidth(320, Qt::SmoothTransformation));
root->addWidget(img);
}
}
auto* buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel);
connect(buttons, &QDialogButtonBox::accepted, this, &QDialog::accept);
connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject);
root->addWidget(buttons);
}
QString AnomalySaveDialog::anomalyName() const {
const QString n = name_->text().trimmed();
return n.isEmpty() ? QStringLiteral("异常") : n;
}
QString AnomalySaveDialog::typeName() const { return type_->currentText(); }
QString AnomalySaveDialog::typeId() const { return type_->currentData().toString(); }
QString AnomalySaveDialog::remark() const { return remark_->toPlainText(); }
} // namespace geopro::app

View File

@ -0,0 +1,31 @@
#pragma once
#include <QDialog>
#include <QString>
class QLineEdit;
class QComboBox;
class QPlainTextEdit;
class QLabel;
namespace geopro::app {
// 异常保存对话框(#4b需求 R50名称 + 异常类型 + 备注 + 截图预览/大小。
// 异常类型本期 mock 列表(真实 exceptionType 端点只读、后续可接。accept 后取 name/typeName/typeId/remark。
class AnomalySaveDialog : public QDialog {
Q_OBJECT
public:
// screenshotPath圈定结束截图的本地路径为空则不显示预览w/h截图像素尺寸R50「确定截图大小」
AnomalySaveDialog(const QString& screenshotPath, int shotW, int shotH, QWidget* parent = nullptr);
QString anomalyName() const;
QString typeName() const;
QString typeId() const;
QString remark() const;
private:
QLineEdit* name_ = nullptr;
QComboBox* type_ = nullptr;
QPlainTextEdit* remark_ = nullptr;
};
} // namespace geopro::app

View File

@ -64,6 +64,7 @@ add_executable(geopro_desktop WIN32
ObjectFormDialog.cpp
ImportDatasetDialog.cpp
ExportDatasetDialog.cpp
AnomalySaveDialog.cpp
SettingsDialog.cpp
SliceExport.cpp
VolumeParamsDialog.cpp

View File

@ -7,6 +7,8 @@
#include <vtkNew.h>
#include <vtkPNGWriter.h>
#include <vtkPointData.h>
#include <vtkRenderWindow.h>
#include <vtkWindowToImageFilter.h>
namespace geopro::app {
@ -19,6 +21,26 @@ bool exportSliceImagePng(vtkImageData* colorImage, const std::string& path) {
return writer->GetErrorCode() == 0;
}
bool captureRenderWindowPng(vtkRenderWindow* win, const std::string& path, int& outW, int& outH) {
outW = outH = 0;
if (win == nullptr || path.empty()) return false;
vtkNew<vtkWindowToImageFilter> w2i;
w2i->SetInput(win);
w2i->ReadFrontBufferOff(); // 用后台缓冲,避免被遮挡污染
w2i->Update();
if (auto* img = w2i->GetOutput()) {
int dims[3];
img->GetDimensions(dims);
outW = dims[0];
outH = dims[1];
}
vtkNew<vtkPNGWriter> writer;
writer->SetFileName(path.c_str());
writer->SetInputConnection(w2i->GetOutputPort());
writer->Write();
return writer->GetErrorCode() == 0;
}
bool exportSliceDat(vtkImageData* slice, const std::string& path) {
if (slice == nullptr || path.empty()) return false;
vtkDataArray* arr = slice->GetPointData() ? slice->GetPointData()->GetScalars() : nullptr;

View File

@ -2,12 +2,16 @@
#include <string>
class vtkImageData;
class vtkRenderWindow;
namespace geopro::app {
// 把切片"上色后"的 2D RGB 影像写为 PNG切片右键「导出为图片」= 导出切片本身,非整窗截图)。
bool exportSliceImagePng(vtkImageData* colorImage, const std::string& path);
// 截整个渲染窗口为 PNG异常标识截图需求 R88成功返回 true并填回截图像素宽高。
bool captureRenderWindowPng(vtkRenderWindow* win, const std::string& path, int& outW, int& outH);
// 把切片重采样 2D 标量影像写为 .dat 文本网格(行=j、列=i空格分隔每格取标量首分量成功返回 true。
bool exportSliceDat(vtkImageData* slice, const std::string& path);

View File

@ -43,6 +43,7 @@
#include <QDate>
#include <QAction>
#include <QCursor>
#include <QDir>
#include <QFileDialog>
#include <QInputDialog>
#include <QLabel>
@ -96,10 +97,12 @@
#include "Logging.hpp"
#include "PanelHeader.hpp"
#include "Theme.hpp"
#include "AnomalySaveDialog.hpp"
#include "SettingsDialog.hpp"
#include "SliceExport.hpp"
#include "TopBar.hpp"
#include "VolumeParamsDialog.hpp"
#include "interact/AnomalyDrawTool.hpp"
#include "ProjectListDialog.hpp"
#include "ObjectFormDialog.hpp"
#include "ImportDatasetDialog.hpp"
@ -271,12 +274,14 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
// 安装自定义拾取样式 + 持活动切片。仅三维 + 有体素可用;切到二维 closeAll。
auto* interactionMgr = new geopro::render::interact::InteractionManager(
renderWindowPtr->GetInteractor(), renderWindowPtr, scene->renderer());
// 异常圈定工具(#4b在切片平面上画多边形高优先级观察者绘制期独占输入
auto* anomalyDrawTool = new geopro::render::interact::AnomalyDrawTool(
renderWindowPtr->GetInteractor(), scene->renderer());
// sceneView->onVolumeChanged 在三栏接线处设置(把体素 image 推给 InteractionManager见下
// 非 QObject 堆对象统一在此清理,按构造逆序:
// interactionMgr(持 interactor/切片观察者) → sceneView(持 scene&) → scene3dRepo → scene。
// interactionMgr 先析构closeAll() 解绑所有切片观察者,再拆 scene/interactor防悬挂崩溃。
// 非 QObject 堆对象统一在此清理,按构造逆序(持 interactor 观察者者先析构,防悬挂崩溃):
QObject::connect(vtkWidget, &QObject::destroyed,
[scene, scene3dRepo, sceneView, interactionMgr]() {
[scene, scene3dRepo, sceneView, interactionMgr, anomalyDrawTool]() {
delete anomalyDrawTool;
delete interactionMgr;
delete sceneView;
delete scene3dRepo;
@ -381,15 +386,31 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
}
};
// 体素变化(重建/清场)后把体素 image 推给 InteractionManager切片基底并调和已保存切片。
sceneView->onVolumeChanged = [interactionMgr, sceneView, syncSlices]() {
// 当前活动体的异常重载渲染(#4b体到场→载其异常 actor体移除→清空。持久化跨重勾可见。
auto reloadAnomalies = [sceneView, scene3dRepo]() {
sceneView->clearAnomalies();
const std::string vol = sceneView->currentVolumeDsId();
if (vol.empty()) return;
scene3dRepo->loadAnomalyTree(
vol,
[sceneView](geopro::data::I3dSceneRepository::AnomalyTree tree) {
for (auto& b : tree.bodies)
for (auto& a : b.members) sceneView->addAnomaly(a);
for (auto& a : tree.loose) sceneView->addAnomaly(a);
},
[](const std::string&) {});
};
// 体素变化(重建/清场)后把体素 image 推给 InteractionManager切片基底并调和已保存切片 + 异常。
sceneView->onVolumeChanged = [interactionMgr, sceneView, syncSlices, reloadAnomalies]() {
if (sceneView->hasVolume())
interactionMgr->setVolumeImage(sceneView->currentVolumeImage(),
sceneView->currentColorScale(), sceneView->currentVmin(),
sceneView->currentVmax());
else
interactionMgr->setVolumeImage(nullptr, sceneView->currentColorScale(), 0.0, 0.0);
syncSlices(); // 体到场/移除后(setVolumeImage 已 closeAll)重建当前体下已勾选的切片
syncSlices(); // 体到场/移除后重建当前体下已勾选的切片
reloadAnomalies(); // 同步重载当前体的异常 actor
};
// ── 三栏抽屉信号 → 控制器/交互Task 7 接线)──────────────────────────────
@ -421,7 +442,8 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
// 保存=按"未保存/已保存"分派(新建+链接+自动勾选 / 覆盖位姿)导出统一为「导出▸图片·dat」
// 正视/翻转/关闭=接现有交互(关闭已保存切片→onSliceClosed 取消列表勾选);创建异常=占位(#4)。
interactionMgr->onSliceContextMenuRequested =
[&window, interactionMgr, sceneView, scene3dRepo, refreshAnalysis, drawer]() {
[&window, interactionMgr, sceneView, scene3dRepo, refreshAnalysis, drawer, anomalyDrawTool,
renderWindowPtr]() {
QMenu menu(&window);
QAction* aAnomaly = menu.addAction(QStringLiteral("创建异常"));
QAction* aSave = menu.addAction(QStringLiteral("保存"));
@ -439,8 +461,66 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
if (chosen == aFlip) { interactionMgr->flipView(); return; }
if (chosen == aClose) { interactionMgr->closeSelected(); return; } // →onSliceClosed→取消列表勾选
if (chosen == aAnomaly) {
QMessageBox::information(&window, QStringLiteral("创建异常"),
QStringLiteral("异常功能开发中(#4切片圈定 + 接真实后端端点)。"));
// 在选中切片平面上启动圈定(左键逐点、右键/回车闭合、Esc 取消)。
namespace ri = geopro::render::interact;
int axis = 3;
ri::Vec3 o{}, p1{}, p2{};
if (!interactionMgr->selectedSlicePlane(axis, o, p1, p2)) return;
const ri::Vec3 e1{{p1[0] - o[0], p1[1] - o[1], p1[2] - o[2]}};
const ri::Vec3 e2{{p2[0] - o[0], p2[1] - o[1], p2[2] - o[2]}};
const ri::Vec3 normal = ri::normalize(ri::cross(e1, e2));
const std::string volId = sceneView->currentVolumeDsId();
anomalyDrawTool->start(
o, normal,
[&window, sceneView, scene3dRepo, renderWindowPtr, refreshAnalysis, volId,
normal, o](const std::vector<ri::Vec3>& worldPts) {
// 草稿异常:先临时渲染(让用户在对话框前看到所画,且截图含异常)。
geopro::core::Anomaly a;
a.markType = geopro::core::AnomalyMarkType::Polygon;
a.volumeDsId = volId;
a.lineColor = "#ff3030";
a.lineWidth = 2.0;
a.dashed = false;
a.planeNormal = {normal[0], normal[1], normal[2]};
a.planeOrigin = {o[0], o[1], o[2]};
for (const auto& p : worldPts) a.worldPts.push_back({p[0], p[1], p[2]});
const std::string draftId = "draft-anomaly";
a.id = draftId;
sceneView->addAnomaly(a);
renderWindowPtr->Render();
// 截图(含异常)→ 临时文件。
const QString shot =
QDir(QDir::tempPath()).filePath(QStringLiteral("geopro_anomaly_shot.png"));
int sw = 0, sh = 0;
geopro::app::captureRenderWindowPng(renderWindowPtr, shot.toStdString(), sw, sh);
geopro::app::AnomalySaveDialog dlg(shot, sw, sh, &window);
if (dlg.exec() != QDialog::Accepted) {
sceneView->removeAnomaly(draftId);
renderWindowPtr->Render();
return;
}
a.id.clear(); // 让仓储生成真实 id
a.name = dlg.anomalyName().toStdString();
a.typeName = dlg.typeName().toStdString();
a.exceptionTypeId = dlg.typeId().toStdString();
a.remark = dlg.remark().toStdString();
geopro::core::Anomaly finalA = a;
scene3dRepo->saveAnomaly(
finalA, shot.toStdString(),
[sceneView, renderWindowPtr, refreshAnalysis, draftId,
finalA](std::string id) mutable {
sceneView->removeAnomaly(draftId); // 撤草稿,换真实 id 重渲染
finalA.id = id;
sceneView->addAnomaly(finalA);
renderWindowPtr->Render();
refreshAnalysis(); // 列表刷新4c 异常面板接入后体现)
},
[&window](const std::string& m) {
QMessageBox::warning(&window, QStringLiteral("保存异常"),
QString::fromStdString(m));
});
},
[]() { /* onCancel放弃无需处理 */ });
return;
}
if (chosen == aSave) {

View File

@ -5,6 +5,7 @@
#include <QAbstractItemView>
#include <QAction>
#include <QComboBox>
#include <QDebug>
#include <QFormLayout>
#include <QHBoxLayout>
#include <QLabel>
@ -116,11 +117,10 @@ Column3DDataset::Column3DDataset(QWidget* parent) : QWidget(parent) {
root->addLayout(row);
}
// 数据集列表(可勾选 = 渲染选择;多选高亮 + 右键 = 生成三维体的源选择,两者独立)
// 数据集列表(可勾选)。勾选 = 渲染为帘面,同时是「生成三维体」的源集合(右键菜单据勾选集生成)。
list_ = new QTreeWidget();
list_->setHeaderHidden(true);
list_->setRootIsDecorated(true);
list_->setSelectionMode(QAbstractItemView::ExtendedSelection); // Ctrl/Shift 多选源剖面
list_->setContextMenuPolicy(Qt::CustomContextMenu);
applyDatasetCardDelegate(list_);
connect(list_, &QTreeWidget::itemChanged, this, [this](QTreeWidgetItem*, int) {
@ -137,15 +137,18 @@ Column3DDataset::Column3DDataset(QWidget* parent) : QWidget(parent) {
}
void Column3DDataset::showListContextMenu(const QPoint& pos) {
// 收集选中项中"可作三维体源"的数据集(反演剖面类)。
// 按**勾选集合**收集"可作三维体源"的数据集(反演剖面类)——与右键点在哪一项无关
static const QSet<QString> kSourceDdCodes = {QStringLiteral("dd_section"),
QStringLiteral("dd_inversion_data")};
QStringList sourceIds;
for (QTreeWidgetItem* item : list_->selectedItems()) {
const QString ddCode = item->data(0, kDsDdCodeRole).toString();
for (QTreeWidgetItemIterator it(list_); *it; ++it) {
if ((*it)->checkState(0) != Qt::Checked) continue; // 仅勾选项
const QString ddCode = (*it)->data(0, kDsDdCodeRole).toString();
if (kSourceDdCodes.contains(ddCode))
sourceIds << item->data(0, kDsIdRole).toString();
sourceIds << (*it)->data(0, kDsIdRole).toString();
}
qInfo().noquote() << "[volsrc] 按勾选收集源 ds 数 =" << sourceIds.size() << ":"
<< sourceIds.join(',');
QMenu menu(this);
QAction* gen = menu.addAction(QStringLiteral("生成三维体"));

View File

@ -11,12 +11,18 @@
namespace geopro::core {
namespace {
// ext包络长度/ cell间距→ 网格点数,限幅 [1, kMaxVolumeDim]。
int clampDim(double ext, double cell) {
// 某轴:优先用 cell 间距;若包络 ext 过大致格数超 kMaxVolumeDim则**放大间距**使 maxDim 格跨满 ext
// (分辨率降低,但**不截断**——否则跨 TM 多剖面相距 > maxDim×cell 时,远端剖面落网格外、丢失)。
void fitAxis(double ext, double cell, double& outCell, int& outN) {
if (!(ext > 0.0) || !(cell > 0.0)) { outCell = (cell > 0.0 ? cell : 1.0); outN = 1; return; }
int n = static_cast<int>(ext / cell) + 1;
if (n < 1) n = 1;
if (n > kMaxVolumeDim) n = kMaxVolumeDim;
return n;
if (n <= kMaxVolumeDim) {
outCell = cell;
outN = (n < 1) ? 1 : n;
return;
}
outN = kMaxVolumeDim;
outCell = ext / static_cast<double>(kMaxVolumeDim - 1); // maxDim 格覆盖全 ext
}
} // namespace
@ -36,13 +42,13 @@ BuiltVolume buildVolume(const PointSet& pts, double cellXY, double cellZ,
minz = std::min(minz, pts.z[i]); maxz = std::max(maxz, pts.z[i]);
}
// 2) GridSpec角点对齐 = 原点取包络最小角)。
// 2) GridSpec角点对齐 = 原点取包络最小角)。间距优先用 cell包络过大时放大间距以覆盖全程
// fitAxis避免跨 TM 多剖面相距过远时远端被截断。
GridSpec spec{};
spec.ox = minx; spec.oy = miny; spec.oz = minz;
spec.dx = cellXY; spec.dy = cellXY; spec.dz = cellZ;
spec.nx = clampDim(maxx - minx, cellXY);
spec.ny = clampDim(maxy - miny, cellXY);
spec.nz = clampDim(maxz - minz, cellZ);
fitAxis(maxx - minx, cellXY, spec.dx, spec.nx);
fitAxis(maxy - miny, cellXY, spec.dy, spec.ny);
fitAxis(maxz - minz, cellZ, spec.dz, spec.nz);
spec.power = power;
spec.maxDist = maxDist;

View File

@ -14,6 +14,8 @@ struct Anomaly {
std::string consortiumId; // 异常体分组 id空 = 未分组/loose
std::string name;
std::string typeName; // exceptionTypeName
std::string exceptionTypeId; // 异常类型 id保存请求 exceptionTypeId
std::string remark; // 备注
AnomalyMarkType markType = AnomalyMarkType::Polyline;
std::vector<Vec2> localPts; // 2D 局部坐标剖面详情x=距离, y=深度)
// VTK 三维:异常多边形/折线/点的世界 3D 坐标(落在所在切片平面上)+ 平面(法向/一点)

View File

@ -1,5 +1,6 @@
#include "api/Api3dRepository.hpp"
#include <QDebug>
#include <QObject>
#include <QString>
#include <QVariant>
@ -133,6 +134,10 @@ void Api3dRepository::finalizeVolume(const std::string& dsId, const core::PointS
vmin = stops.front();
vmax = stops.back();
}
qInfo().noquote() << "[volbuild] finalize pts=" << pts.v.size() << "grid"
<< bv.spec.nx << "x" << bv.spec.ny << "x" << bv.spec.nz
<< "origin" << bv.spec.ox << bv.spec.oy << bv.spec.oz << "spacing"
<< bv.spec.dx << bv.spec.dy << bv.spec.dz;
VolumeGrid out{std::move(bv.vol),
{{bv.spec.ox, bv.spec.oy, bv.spec.oz}},
{{bv.spec.dx, bv.spec.dy, bv.spec.dz}},
@ -182,9 +187,14 @@ void Api3dRepository::loadVolume(const std::string& dsId,
for (const std::string& srcId : params.sourceDatasetIds) {
loadSection(
srcId,
[this, dsId, params, agg, onOk, onErr](SectionData s) {
[this, dsId, srcId, params, agg, onOk, onErr](SectionData s) {
if (agg->failed) return;
const std::size_t before = agg->pts.v.size();
appendGridPoints(s.grid, agg->pts);
qInfo().noquote() << "[volbuild] source" << QString::fromStdString(srcId)
<< "grid" << s.grid.nx() << "x" << s.grid.ny() << "-> +"
<< (agg->pts.v.size() - before) << "pts (total"
<< agg->pts.v.size() << ")";
if (!agg->haveScale) {
agg->scale = s.scale;
agg->haveScale = true;

View File

@ -2,7 +2,7 @@ find_package(VTK REQUIRED COMPONENTS CommonCore CommonDataModel FiltersGeometry
find_package(GDAL CONFIG REQUIRED)
add_library(geopro_render STATIC
Scene.cpp ColorLutBuilder.cpp CameraPreset.cpp VoxelFromScatters.cpp ContourBands.cpp actors/GridContourActor.cpp actors/VoxelActor.cpp actors/CurtainActor.cpp actors/MapLineActor.cpp actors/ScatterActor.cpp actors/AnomalyActor.cpp actors/ElectrodeActor.cpp actors/TerrainActor.cpp actors/AxesActor.cpp
interact/SlicePlaneMath.cpp interact/SliceTool.cpp interact/PickInteractorStyle.cpp interact/InteractionManager.cpp
interact/SlicePlaneMath.cpp interact/SliceTool.cpp interact/PickInteractorStyle.cpp interact/InteractionManager.cpp interact/AnomalyDrawTool.cpp
ground/TileMath.cpp)
target_include_directories(geopro_render PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
target_link_libraries(geopro_render PUBLIC geopro_core ${VTK_LIBRARIES} GDAL::GDAL)

View File

@ -0,0 +1,276 @@
#include "interact/AnomalyDrawTool.hpp"
#include <chrono>
#include <cmath>
#include <cstddef>
#include <string>
#include <vtkActor.h>
#include <vtkCallbackCommand.h>
#include <vtkCellArray.h>
#include <vtkCommand.h>
#include <vtkNew.h>
#include <vtkPoints.h>
#include <vtkPolyData.h>
#include <vtkPolyDataMapper.h>
#include <vtkPolyLine.h>
#include <vtkProperty.h>
#include <vtkRenderWindowInteractor.h>
#include <vtkRenderer.h>
#include <vtkTextActor.h>
#include <vtkTextProperty.h>
namespace geopro::render::interact {
namespace {
constexpr double kEps = 1e-9;
constexpr double kObserverPriority = 5.0; // 高于切片 widget 与右键菜单(1.0),绘制期独占
constexpr double kDoubleClickMs = 350.0; // 左键双击闭合阈值
constexpr int kClickSlopPx = 6; // 双击位置相近阈值(px)
double nowMs() {
return std::chrono::duration<double, std::milli>(
std::chrono::steady_clock::now().time_since_epoch())
.count();
}
} // namespace
AnomalyDrawTool::AnomalyDrawTool(vtkRenderWindowInteractor* interactor, vtkRenderer* renderer)
: interactor_(interactor), renderer_(renderer) {}
AnomalyDrawTool::~AnomalyDrawTool() { removeObservers(); }
void AnomalyDrawTool::start(const Vec3& planeOrigin, const Vec3& planeNormal,
std::function<void(const std::vector<Vec3>&)> onFinish,
std::function<void()> onCancel) {
if (active_) cancel();
origin_ = planeOrigin;
normal_ = normalize(planeNormal);
onFinish_ = std::move(onFinish);
onCancel_ = std::move(onCancel);
pts_.clear();
lastClickMs_ = -1.0;
hasCursor_ = false;
active_ = true;
installObservers();
// 屏幕操作提示(左上角),解决"不知如何闭合"。
if (renderer_) {
hint_ = vtkSmartPointer<vtkTextActor>::New();
hint_->SetInput("圈定异常:左键逐点 · 双击或右键完成 · Esc 取消");
hint_->GetTextProperty()->SetFontSize(16);
hint_->GetTextProperty()->SetColor(1.0, 0.9, 0.0);
hint_->GetPositionCoordinate()->SetCoordinateSystemToNormalizedViewport();
hint_->GetPositionCoordinate()->SetValue(0.02, 0.94);
renderer_->AddViewProp(hint_);
if (interactor_) interactor_->Render();
}
}
void AnomalyDrawTool::cancel() {
if (!active_) return;
auto cb = onCancel_;
teardownActive(); // 先清理状态,回调里可能再 start
if (cb) cb();
}
// 提取:清理活动态(移观察者/预览/置 inactive不触发回调。
void AnomalyDrawTool::teardownActive() {
removeObservers();
if (renderer_) {
if (preview_) renderer_->RemoveViewProp(preview_);
if (rubber_) renderer_->RemoveViewProp(rubber_);
if (hint_) renderer_->RemoveViewProp(hint_);
}
preview_ = nullptr;
rubber_ = nullptr;
hint_ = nullptr;
active_ = false;
hasCursor_ = false;
pts_.clear();
if (interactor_) interactor_->Render();
}
Vec3 AnomalyDrawTool::pickOnPlane() const {
const int* pos = interactor_->GetEventPosition();
// 屏幕点 → 世界近/远点(齐次,需除 w
auto toWorld = [this](int x, int y, double z) -> Vec3 {
renderer_->SetDisplayPoint(static_cast<double>(x), static_cast<double>(y), z);
renderer_->DisplayToWorld();
double w[4];
renderer_->GetWorldPoint(w);
if (std::abs(w[3]) > kEps) {
w[0] /= w[3]; w[1] /= w[3]; w[2] /= w[3];
}
return Vec3{{w[0], w[1], w[2]}};
};
const Vec3 nearP = toWorld(pos[0], pos[1], 0.0);
const Vec3 farP = toWorld(pos[0], pos[1], 1.0);
const Vec3 dir{{farP[0] - nearP[0], farP[1] - nearP[1], farP[2] - nearP[2]}};
const double denom = dot(dir, normal_);
if (std::abs(denom) < kEps) return nearP; // 射线平行平面 → 退化用近点
// t = ((origin - near)·normal) / (dir·normal)
const Vec3 on{{origin_[0] - nearP[0], origin_[1] - nearP[1], origin_[2] - nearP[2]}};
const double t = dot(on, normal_) / denom;
return Vec3{{nearP[0] + t * dir[0], nearP[1] + t * dir[1], nearP[2] + t * dir[2]}};
}
void AnomalyDrawTool::addVertex() {
pts_.push_back(pickOnPlane());
updatePreview();
}
void AnomalyDrawTool::updatePreview() {
if (!renderer_) return;
if (preview_) renderer_->RemoveViewProp(preview_);
preview_ = nullptr;
if (pts_.empty()) {
interactor_->Render();
return;
}
vtkNew<vtkPoints> points;
points->SetNumberOfPoints(static_cast<vtkIdType>(pts_.size()));
for (std::size_t i = 0; i < pts_.size(); ++i)
points->SetPoint(static_cast<vtkIdType>(i), pts_[i][0], pts_[i][1], pts_[i][2]);
vtkNew<vtkPolyData> poly;
poly->SetPoints(points);
// 顶点圆点:每点一个 vtkVertex → 单点也可见(解决"第一下看不到点在哪")。
vtkNew<vtkCellArray> verts;
for (std::size_t i = 0; i < pts_.size(); ++i) {
const auto id = static_cast<vtkIdType>(i);
verts->InsertNextCell(1, &id);
}
poly->SetVerts(verts);
// 实线折线≥2 点)。
if (pts_.size() >= 2) {
vtkNew<vtkPolyLine> line;
line->GetPointIds()->SetNumberOfIds(static_cast<vtkIdType>(pts_.size()));
for (std::size_t i = 0; i < pts_.size(); ++i)
line->GetPointIds()->SetId(static_cast<vtkIdType>(i), static_cast<vtkIdType>(i));
vtkNew<vtkCellArray> cells;
cells->InsertNextCell(line);
poly->SetLines(cells);
}
vtkNew<vtkPolyDataMapper> mapper;
mapper->SetInputData(poly);
mapper->ScalarVisibilityOff();
preview_ = vtkSmartPointer<vtkActor>::New();
preview_->SetMapper(mapper);
preview_->GetProperty()->SetColor(1.0, 0.9, 0.0); // 亮黄
preview_->GetProperty()->SetLineWidth(2.0);
preview_->GetProperty()->SetPointSize(9.0); // 醒目圆点
renderer_->AddActor(preview_);
interactor_->Render();
}
void AnomalyDrawTool::updateRubber() {
if (!renderer_) return;
if (rubber_) renderer_->RemoveViewProp(rubber_);
rubber_ = nullptr;
if (pts_.empty() || !hasCursor_) {
if (interactor_) interactor_->Render();
return;
}
// 末点 → 当前光标投影点 的虚线橡皮筋(跟手反馈)。
const Vec3& a = pts_.back();
vtkNew<vtkPoints> points;
points->SetNumberOfPoints(2);
points->SetPoint(0, a[0], a[1], a[2]);
points->SetPoint(1, cursorPt_[0], cursorPt_[1], cursorPt_[2]);
vtkNew<vtkPolyData> poly;
poly->SetPoints(points);
vtkNew<vtkPolyLine> line;
line->GetPointIds()->SetNumberOfIds(2);
line->GetPointIds()->SetId(0, 0);
line->GetPointIds()->SetId(1, 1);
vtkNew<vtkCellArray> cells;
cells->InsertNextCell(line);
poly->SetLines(cells);
vtkNew<vtkPolyDataMapper> mapper;
mapper->SetInputData(poly);
mapper->ScalarVisibilityOff();
rubber_ = vtkSmartPointer<vtkActor>::New();
rubber_->SetMapper(mapper);
rubber_->GetProperty()->SetColor(1.0, 0.9, 0.0);
rubber_->GetProperty()->SetLineWidth(1.5);
rubber_->GetProperty()->SetLineStipplePattern(0xF0F0); // 虚线
rubber_->GetProperty()->SetLineStippleRepeatFactor(1);
renderer_->AddActor(rubber_);
interactor_->Render();
}
void AnomalyDrawTool::finish() {
if (pts_.size() < 3) { // 不足以成面 → 取消
cancel();
return;
}
std::vector<Vec3> result = pts_;
auto cb = onFinish_;
teardownActive();
if (cb) cb(result);
}
void AnomalyDrawTool::installObservers() {
if (!interactor_) return;
cmd_ = vtkSmartPointer<vtkCallbackCommand>::New();
cmd_->SetClientData(this);
cmd_->SetCallback([](vtkObject*, unsigned long eid, void* client, void*) {
auto* self = static_cast<AnomalyDrawTool*>(client);
if (!self->active_) return;
if (eid == vtkCommand::MouseMoveEvent) {
// 鼠标移动:更新末点→光标的虚线橡皮筋(跟手反馈)。不 abort不干扰其它悬停。
self->cursorPt_ = self->pickOnPlane();
self->hasCursor_ = true;
self->updateRubber();
return;
}
// 先消费事件abort再处理finish()/cancel() 内 teardown 会置空 cmd_若 abort 留到末尾会被跳过,
// 导致触发闭合的那次按键漏给切片 widget → widget 当左键按下开始 slice-motion鼠标一动切片就动
if (self->cmd_) self->cmd_->SetAbortFlag(1);
switch (eid) {
case vtkCommand::LeftButtonPressEvent: {
// 左键双连击 = 闭合(标准多边形交互);否则加顶点。
const double now = nowMs();
const int* p = self->interactor_->GetEventPosition();
const bool dbl = self->lastClickMs_ >= 0.0 &&
(now - self->lastClickMs_) < kDoubleClickMs &&
std::abs(p[0] - self->lastClickX_) <= kClickSlopPx &&
std::abs(p[1] - self->lastClickY_) <= kClickSlopPx;
self->lastClickMs_ = now;
self->lastClickX_ = p[0];
self->lastClickY_ = p[1];
if (dbl)
self->finish();
else
self->addVertex();
break;
}
case vtkCommand::RightButtonPressEvent: self->finish(); break;
case vtkCommand::KeyPressEvent: {
const char* key = self->interactor_->GetKeySym();
if (key && (std::string(key) == "Escape")) self->cancel();
else if (key && (std::string(key) == "Return")) self->finish();
break;
}
default: break;
}
});
tagLeft_ = interactor_->AddObserver(vtkCommand::LeftButtonPressEvent, cmd_, kObserverPriority);
tagRight_ = interactor_->AddObserver(vtkCommand::RightButtonPressEvent, cmd_, kObserverPriority);
tagKey_ = interactor_->AddObserver(vtkCommand::KeyPressEvent, cmd_, kObserverPriority);
tagMove_ = interactor_->AddObserver(vtkCommand::MouseMoveEvent, cmd_, kObserverPriority);
}
void AnomalyDrawTool::removeObservers() {
if (interactor_) {
if (tagLeft_) interactor_->RemoveObserver(tagLeft_);
if (tagRight_) interactor_->RemoveObserver(tagRight_);
if (tagKey_) interactor_->RemoveObserver(tagKey_);
if (tagMove_) interactor_->RemoveObserver(tagMove_);
if (tagDbl_) interactor_->RemoveObserver(tagDbl_);
}
tagLeft_ = tagRight_ = tagKey_ = tagMove_ = tagDbl_ = 0;
cmd_ = nullptr;
}
} // namespace geopro::render::interact

View File

@ -0,0 +1,69 @@
#pragma once
#include <functional>
#include <vector>
#include <vtkSmartPointer.h>
#include "interact/SlicePlaneMath.hpp"
class vtkRenderWindowInteractor;
class vtkRenderer;
class vtkActor;
class vtkTextActor;
class vtkCallbackCommand;
namespace geopro::render::interact {
// 异常圈定工具(#4b在给定切片平面上交互式画多边形。
// 左键逐点加顶点(屏幕射线与平面求交,落在平面上);右键 / 双击 / 回车 闭合 → onFinish(worldPts)
// Esc / 不足 3 点闭合 → onCancel。绘制中实时预览折线。
// 高优先级(2.0)交互器观察者抢输入:先于切片 widget 与 InteractionManager 右键菜单,绘制期独占左右键。
// render 层:只碰 VTK不认业务产物(平面上的世界点)经回调交上层组装 core::Anomaly。
class AnomalyDrawTool {
public:
AnomalyDrawTool(vtkRenderWindowInteractor* interactor, vtkRenderer* renderer);
~AnomalyDrawTool();
AnomalyDrawTool(const AnomalyDrawTool&) = delete;
AnomalyDrawTool& operator=(const AnomalyDrawTool&) = delete;
// 开始在平面(origin/normal)上圈定。onFinish 收闭合多边形顶点(世界系)onCancel 取消。
void start(const Vec3& planeOrigin, const Vec3& planeNormal,
std::function<void(const std::vector<Vec3>&)> onFinish,
std::function<void()> onCancel);
bool active() const { return active_; }
void cancel(); // 外部强制取消(如切走视图)
private:
void addVertex(); // 左键:加顶点
void updatePreview(); // 重建已点几何(顶点圆点 + 实线折线;单点也可见)
void updateRubber(); // 鼠标移动:末点→光标的虚线橡皮筋
void finish(); // 右键/双击/回车:闭合
Vec3 pickOnPlane() const; // 当前鼠标屏幕点 → 射线与平面交点
void installObservers();
void removeObservers();
void teardownActive(); // 清理活动态(移观察者/预览/置inactive),不触发回调
vtkRenderWindowInteractor* interactor_;
vtkRenderer* renderer_;
bool active_ = false;
Vec3 origin_{{0, 0, 0}}, normal_{{0, 0, 1}};
std::vector<Vec3> pts_;
std::function<void(const std::vector<Vec3>&)> onFinish_;
std::function<void()> onCancel_;
vtkSmartPointer<vtkActor> preview_; // 已点几何(顶点圆点 + 实线折线)
vtkSmartPointer<vtkActor> rubber_; // 末点→光标 虚线橡皮筋
vtkSmartPointer<vtkTextActor> hint_; // 屏幕操作提示
Vec3 cursorPt_{{0, 0, 0}}; // 当前鼠标在切面上的投影点
bool hasCursor_ = false;
vtkSmartPointer<vtkCallbackCommand> cmd_;
unsigned long tagLeft_ = 0, tagMove_ = 0, tagRight_ = 0, tagKey_ = 0, tagDbl_ = 0;
// 双击闭合检测(左键两连击):记上次左键时刻 + 屏幕位置。
double lastClickMs_ = -1.0;
int lastClickX_ = 0, lastClickY_ = 0;
};
} // namespace geopro::render::interact

View File

@ -281,24 +281,25 @@ vtkSmartPointer<vtkImageData> InteractionManager::selectedSliceColorImage() cons
return out;
}
void InteractionManager::handleRightButton() {
// 高优先级右键观察者(先于 vtkImagePlaneWidget 消费右键)。
// 选中目标 = 拾取命中的切片;拾取没命中切片平面(实测常因拾到体/其它面而落在阈值外)则
// 回退到"当前选中切片"(左键交互/新建已选中)。有可操作切片 → abort 右键 + 弹菜单;否则放行。
if (!interactor_) return;
int idx = -1;
int InteractionManager::pickSliceAtCursor() const {
if (!interactor_ || slices_.empty()) return -1;
const int* pos = interactor_->GetEventPosition();
auto* ren = interactor_->FindPokedRenderer(pos[0], pos[1]);
if (ren) {
vtkNew<vtkCellPicker> picker;
picker->SetTolerance(0.005);
if (picker->Pick(pos[0], pos[1], 0.0, ren)) {
double w[3];
picker->GetPickPosition(w);
idx = nearestSlice({w[0], w[1], w[2]});
}
}
if (!ren) return -1;
vtkNew<vtkCellPicker> picker;
picker->SetTolerance(0.005);
if (!picker->Pick(pos[0], pos[1], 0.0, ren)) return -1;
double w[3];
picker->GetPickPosition(w);
return nearestSlice({w[0], w[1], w[2]});
}
void InteractionManager::handleRightButton() {
// 高优先级右键观察者(先于 vtkImagePlaneWidget 消费右键)。
// 选中目标 = 拾取命中的切片;拾取没命中(常因拾到体/其它面)则回退到"当前选中切片"。
// 有可操作切片 → abort 右键 + 弹菜单;否则放行默认右键。
if (!interactor_) return;
int idx = pickSliceAtCursor();
if (idx < 0) idx = selected_; // 回退到当前选中切片
if (idx < 0 || idx >= static_cast<int>(slices_.size())) return; // 无切片可操作 → 放行默认右键
selected_ = idx;
@ -329,14 +330,10 @@ int InteractionManager::nearestSlice(const Vec3& worldPoint) const {
}
void InteractionManager::onPicked(const Vec3& worldPoint) {
// 单击 = 仅选中命中切片 + 高亮,**不动相机** → 切换切片永不跳。
// 拖动旋转交给默认 TrackballCamera绕场景/体中心,稳定)。曾试"按切片中心移焦点"以实现
// spec C38'以切片为中心',但切片中心≈体中心→与默认视觉等价、却引入切换跳动,得不偿失,故去除。
const int idx = nearestSlice(worldPoint);
if (idx >= 0) {
selected_ = idx;
updateSelectionVisual();
}
// 单击 = 选中命中切片;点在切片外(如点到体/帘面)→ 取消选中idx=-1。**不动相机**。
// 解决"选了切片无法取消":点击切片之外即清选中,滚轮恢复缩放(见 onWheel
selected_ = nearestSlice(worldPoint);
updateSelectionVisual();
safeRender();
}
@ -367,11 +364,14 @@ void InteractionManager::faceSlice(int idx) {
bool InteractionManager::onWheel(int dir) {
// 滚轮推进**当前选中**的切片(需先显式选中);无选中 → 不消费 → 相机缩放。
// 配合 onPicked 的"点击切片外取消选中":取消后滚轮即恢复缩放,解决"选了切片无法缩放"。
// (不采用"悬停即推进":推进时鼠标难持续压在移动的切片上,且过敏感。)
if (selected_ < 0 || selected_ >= static_cast<int>(slices_.size())) return false;
const double step = wheelStep(imageBounds(image_), dir);
slices_[static_cast<std::size_t>(selected_)]->advance(step);
safeRender();
return true; // 消费滚轮(不缩放)
return true; // 消费滚轮(推进选中切片,不缩放)
}
} // namespace geopro::render::interact

View File

@ -104,6 +104,8 @@ private:
// 找离世界点最近的切片索引;无切片返回 -1。
int nearestSlice(const Vec3& worldPoint) const;
// 在当前鼠标屏幕位置拾取 → 命中的切片索引;未命中切片返回 -1。
int pickSliceAtCursor() const;
// 按 SliceTool 指针设为选中widget 交互回调用:触碰即选中)。
void selectByTool(const SliceTool* tool);
// 相机正视给定切面focal=center, 沿 normal 退 dist