From 6210d615f3027077222efdd9391f03c240dad79c Mon Sep 17 00:00:00 2001 From: gaozheng Date: Thu, 18 Jun 2026 18:31:46 +0800 Subject: [PATCH] =?UTF-8?q?feat(vtk):=20=E5=BC=82=E5=B8=B8=E5=9C=88?= =?UTF-8?q?=E5=AE=9A+=E4=BF=9D=E5=AD=98=E9=97=AD=E7=8E=AF(#4b)+=E5=A4=9A?= =?UTF-8?q?=E9=A1=B9=E4=BA=A4=E4=BA=92=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #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+显隐+删除)。 --- docs/superpowers/HANDOFF-vtk-3d.md | 5 +- .../plans/2026-06-18-vtk-3d-anomaly.md | 35 ++- ...6-17-vtk-3d-volume-slice-anomaly-design.md | 7 + src/app/AnomalySaveDialog.cpp | 72 +++++ src/app/AnomalySaveDialog.hpp | 31 ++ src/app/CMakeLists.txt | 1 + src/app/SliceExport.cpp | 22 ++ src/app/SliceExport.hpp | 4 + src/app/main.cpp | 100 ++++++- src/app/panels/columns/Column3DDataset.cpp | 15 +- src/core/algo/VolumeBuilder.cpp | 26 +- src/core/model/Anomaly.hpp | 2 + src/data/api/Api3dRepository.cpp | 12 +- src/render/CMakeLists.txt | 2 +- src/render/interact/AnomalyDrawTool.cpp | 276 ++++++++++++++++++ src/render/interact/AnomalyDrawTool.hpp | 69 +++++ src/render/interact/InteractionManager.cpp | 50 ++-- src/render/interact/InteractionManager.hpp | 2 + 18 files changed, 663 insertions(+), 68 deletions(-) create mode 100644 src/app/AnomalySaveDialog.cpp create mode 100644 src/app/AnomalySaveDialog.hpp create mode 100644 src/render/interact/AnomalyDrawTool.cpp create mode 100644 src/render/interact/AnomalyDrawTool.hpp diff --git a/docs/superpowers/HANDOFF-vtk-3d.md b/docs/superpowers/HANDOFF-vtk-3d.md index c8b8d90..cd150c7 100644 --- a/docs/superpowers/HANDOFF-vtk-3d.md +++ b/docs/superpowers/HANDOFF-vtk-3d.md @@ -58,7 +58,10 @@ - **已保存切片重渲染**:分析栏勾选→`syncSlices`在当前活动体上还原(`showSavedSlice`),取消→移除;靠`onVolumeChanged→syncSlices`解决父体异步到场。dd_slice 不进控制器(避免 loadSection 失败),main 编排走 InteractionManager。 - **场景↔列表同步**:VTK「关闭」已保存切片→`onSliceClosed`→列表取消勾选。`Column3DAnalysis::setDatasets`按 dsId 保留勾选+仅勾选集变化才发信号(修"保存切片连带取消体勾选/列表重置")。 - 导出:`SliceExport.{hpp,cpp}`(图片=切片上采样2048上色 PNG;dat=重采样标量网格)。切片持久化=`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. 三维体/切片/异常详情面板(源数据/插值参数/色阶/测量点数体积/异常列表)。 - **其它小欠项**:三维分析栏完整三级树"对象→三维体→切片"里"对象"根层未套(体目前是顶层);真实色阶编辑。 diff --git a/docs/superpowers/plans/2026-06-18-vtk-3d-anomaly.md b/docs/superpowers/plans/2026-06-18-vtk-3d-anomaly.md index b6d7d40..14a32a6 100644 --- a/docs/superpowers/plans/2026-06-18-vtk-3d-anomaly.md +++ b/docs/superpowers/plans/2026-06-18-vtk-3d-anomaly.md @@ -63,27 +63,34 @@ - 异常体(consortium)分组:mock 内存(`map`);真实端点 `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. 风险/待确认 diff --git a/docs/superpowers/specs/2026-06-17-vtk-3d-volume-slice-anomaly-design.md b/docs/superpowers/specs/2026-06-17-vtk-3d-volume-slice-anomaly-design.md index 5ff8d92..aef0dfd 100644 --- a/docs/superpowers/specs/2026-06-17-vtk-3d-volume-slice-anomaly-design.md +++ b/docs/superpowers/specs/2026-06-17-vtk-3d-volume-slice-anomaly-design.md @@ -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. 核心数据模型 diff --git a/src/app/AnomalySaveDialog.cpp b/src/app/AnomalySaveDialog.cpp new file mode 100644 index 0000000..26a39da --- /dev/null +++ b/src/app/AnomalySaveDialog.cpp @@ -0,0 +1,72 @@ +#include "AnomalySaveDialog.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +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 diff --git a/src/app/AnomalySaveDialog.hpp b/src/app/AnomalySaveDialog.hpp new file mode 100644 index 0000000..1bb8f10 --- /dev/null +++ b/src/app/AnomalySaveDialog.hpp @@ -0,0 +1,31 @@ +#pragma once +#include +#include + +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 diff --git a/src/app/CMakeLists.txt b/src/app/CMakeLists.txt index 5649f48..b181dd7 100644 --- a/src/app/CMakeLists.txt +++ b/src/app/CMakeLists.txt @@ -64,6 +64,7 @@ add_executable(geopro_desktop WIN32 ObjectFormDialog.cpp ImportDatasetDialog.cpp ExportDatasetDialog.cpp + AnomalySaveDialog.cpp SettingsDialog.cpp SliceExport.cpp VolumeParamsDialog.cpp diff --git a/src/app/SliceExport.cpp b/src/app/SliceExport.cpp index 3210301..66da84c 100644 --- a/src/app/SliceExport.cpp +++ b/src/app/SliceExport.cpp @@ -7,6 +7,8 @@ #include #include #include +#include +#include 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 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 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; diff --git a/src/app/SliceExport.hpp b/src/app/SliceExport.hpp index 5046759..0325ba8 100644 --- a/src/app/SliceExport.hpp +++ b/src/app/SliceExport.hpp @@ -2,12 +2,16 @@ #include 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); diff --git a/src/app/main.cpp b/src/app/main.cpp index 16bb2ae..c1ed132 100644 --- a/src/app/main.cpp +++ b/src/app/main.cpp @@ -43,6 +43,7 @@ #include #include #include +#include #include #include #include @@ -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& 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) { diff --git a/src/app/panels/columns/Column3DDataset.cpp b/src/app/panels/columns/Column3DDataset.cpp index ecbd81f..c3e4c9b 100644 --- a/src/app/panels/columns/Column3DDataset.cpp +++ b/src/app/panels/columns/Column3DDataset.cpp @@ -5,6 +5,7 @@ #include #include #include +#include #include #include #include @@ -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 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("生成三维体")); diff --git a/src/core/algo/VolumeBuilder.cpp b/src/core/algo/VolumeBuilder.cpp index 6a35198..e298d97 100644 --- a/src/core/algo/VolumeBuilder.cpp +++ b/src/core/algo/VolumeBuilder.cpp @@ -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(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(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; diff --git a/src/core/model/Anomaly.hpp b/src/core/model/Anomaly.hpp index f05983d..3a938b2 100644 --- a/src/core/model/Anomaly.hpp +++ b/src/core/model/Anomaly.hpp @@ -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 localPts; // 2D 局部坐标(剖面详情:x=距离, y=深度) // VTK 三维:异常多边形/折线/点的世界 3D 坐标(落在所在切片平面上)+ 平面(法向/一点), diff --git a/src/data/api/Api3dRepository.cpp b/src/data/api/Api3dRepository.cpp index 61ae193..689e247 100644 --- a/src/data/api/Api3dRepository.cpp +++ b/src/data/api/Api3dRepository.cpp @@ -1,5 +1,6 @@ #include "api/Api3dRepository.hpp" +#include #include #include #include @@ -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; diff --git a/src/render/CMakeLists.txt b/src/render/CMakeLists.txt index 64b8e69..9857aa6 100644 --- a/src/render/CMakeLists.txt +++ b/src/render/CMakeLists.txt @@ -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) diff --git a/src/render/interact/AnomalyDrawTool.cpp b/src/render/interact/AnomalyDrawTool.cpp new file mode 100644 index 0000000..2cff543 --- /dev/null +++ b/src/render/interact/AnomalyDrawTool.cpp @@ -0,0 +1,276 @@ +#include "interact/AnomalyDrawTool.hpp" + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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( + 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&)> onFinish, + std::function 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::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(x), static_cast(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 points; + points->SetNumberOfPoints(static_cast(pts_.size())); + for (std::size_t i = 0; i < pts_.size(); ++i) + points->SetPoint(static_cast(i), pts_[i][0], pts_[i][1], pts_[i][2]); + vtkNew poly; + poly->SetPoints(points); + // 顶点圆点:每点一个 vtkVertex → 单点也可见(解决"第一下看不到点在哪")。 + vtkNew verts; + for (std::size_t i = 0; i < pts_.size(); ++i) { + const auto id = static_cast(i); + verts->InsertNextCell(1, &id); + } + poly->SetVerts(verts); + // 实线折线(≥2 点)。 + if (pts_.size() >= 2) { + vtkNew line; + line->GetPointIds()->SetNumberOfIds(static_cast(pts_.size())); + for (std::size_t i = 0; i < pts_.size(); ++i) + line->GetPointIds()->SetId(static_cast(i), static_cast(i)); + vtkNew cells; + cells->InsertNextCell(line); + poly->SetLines(cells); + } + vtkNew mapper; + mapper->SetInputData(poly); + mapper->ScalarVisibilityOff(); + preview_ = vtkSmartPointer::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 points; + points->SetNumberOfPoints(2); + points->SetPoint(0, a[0], a[1], a[2]); + points->SetPoint(1, cursorPt_[0], cursorPt_[1], cursorPt_[2]); + vtkNew poly; + poly->SetPoints(points); + vtkNew line; + line->GetPointIds()->SetNumberOfIds(2); + line->GetPointIds()->SetId(0, 0); + line->GetPointIds()->SetId(1, 1); + vtkNew cells; + cells->InsertNextCell(line); + poly->SetLines(cells); + vtkNew mapper; + mapper->SetInputData(poly); + mapper->ScalarVisibilityOff(); + rubber_ = vtkSmartPointer::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 result = pts_; + auto cb = onFinish_; + teardownActive(); + if (cb) cb(result); +} + +void AnomalyDrawTool::installObservers() { + if (!interactor_) return; + cmd_ = vtkSmartPointer::New(); + cmd_->SetClientData(this); + cmd_->SetCallback([](vtkObject*, unsigned long eid, void* client, void*) { + auto* self = static_cast(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 diff --git a/src/render/interact/AnomalyDrawTool.hpp b/src/render/interact/AnomalyDrawTool.hpp new file mode 100644 index 0000000..ef5d692 --- /dev/null +++ b/src/render/interact/AnomalyDrawTool.hpp @@ -0,0 +1,69 @@ +#pragma once +#include +#include + +#include + +#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&)> onFinish, + std::function 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 pts_; + std::function&)> onFinish_; + std::function onCancel_; + + vtkSmartPointer preview_; // 已点几何(顶点圆点 + 实线折线) + vtkSmartPointer rubber_; // 末点→光标 虚线橡皮筋 + vtkSmartPointer hint_; // 屏幕操作提示 + Vec3 cursorPt_{{0, 0, 0}}; // 当前鼠标在切面上的投影点 + bool hasCursor_ = false; + vtkSmartPointer 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 diff --git a/src/render/interact/InteractionManager.cpp b/src/render/interact/InteractionManager.cpp index a8d909e..ed179df 100644 --- a/src/render/interact/InteractionManager.cpp +++ b/src/render/interact/InteractionManager.cpp @@ -281,24 +281,25 @@ vtkSmartPointer 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 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 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(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(slices_.size())) return false; const double step = wheelStep(imageBounds(image_), dir); slices_[static_cast(selected_)]->advance(step); safeRender(); - return true; // 消费滚轮(不缩放) + return true; // 消费滚轮(推进选中切片,不缩放) } } // namespace geopro::render::interact diff --git a/src/render/interact/InteractionManager.hpp b/src/render/interact/InteractionManager.hpp index 74fd471..a35b7b0 100644 --- a/src/render/interact/InteractionManager.hpp +++ b/src/render/interact/InteractionManager.hpp @@ -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)。