feat/3d-radar-volume-ingest #9
|
|
@ -0,0 +1,115 @@
|
|||
# 三维雷达 导入→处理→渲染 全链路方案(结合 POC 评估)
|
||||
|
||||
> 2026-06-30。本文把用户给出的雷达产品目标落成方案,并结合 POC(明星路 13G)已验证的资产,
|
||||
> 评估复用/缺口、定关键架构缝与决策、排风险与分期。范围限定**三维雷达**(渲染/切片/异常 +
|
||||
> 其上游的导入/处理管线);不含 2D 雷达图、不含后端反演链。
|
||||
|
||||
## 1. 用户目标(七步)
|
||||
|
||||
1. 设备经 USB 接到用户电脑。
|
||||
2. 客户端「设备连接」功能:自动识别设备、打开 USB 存储,用户选文件导入。
|
||||
3. 另一导入分支:文件已在用户电脑,经**文件夹选择**导入。
|
||||
4. 导入过程按文件类型(不同型号雷达)**自动加载插件**,对数据做 **ds 标准化转换**。
|
||||
5. 导入完成自动形成 **项目 / GS / TM / ds** 结构(建 GS/TM 的「方案」待细化)。
|
||||
6. 数据集详情页:用**数据处理插件**处理原始数据,插件支持多种方法(用户勾选);
|
||||
另**固定加入两个客户端内置处理方法**:**插值**、**预渲染**(即为 LOD 做准备)。
|
||||
处理后**存为新 ds**。
|
||||
7. VTK 视图:选三维雷达 ds 渲染、切片等;**所选 ds 可能是未处理原数据,也可能是处理后数据**(不同 ds)。
|
||||
|
||||
## 2. 关键决策(用户已拍板)
|
||||
|
||||
- **D1 — 预渲染(LOD 烘焙)是可选的。** 默认勾选,但用户可取消。
|
||||
→ **渲染路径必须同时支持「未预渲染」与「已预渲染」两类 ds**(不能假设所有大体都已烘焙 LOD)。
|
||||
→ 采用**混合渲染源**(见 §4):原/插值 ds 走整卷源;预渲染 ds 走 LOD 源。
|
||||
|
||||
## 3. POC ↔ 目标 映射(复用 vs 缺口)
|
||||
|
||||
**结论:算法基本齐(POC/app 已有标准化、插值、增益、LOD 引擎、渲染源抽象);缺的是三层"框架/管线"
|
||||
+ 设备接入。**
|
||||
|
||||
| 步骤 | 已有(POC/现状) | 缺口 |
|
||||
|---|---|---|
|
||||
| 1–2 设备 USB/存储 | 无 | **全新**:Windows 设备识别 + USB 盘浏览(与 POC 无关,纯平台 plumbing) |
|
||||
| 3 文件夹导入 | 已有导入入口、`tools/radar_convert` malamira 转换器 | 文件夹选择 + 批量 |
|
||||
| 4 按型号插件标准化 | 转换算法有(malamira→规范化 `.head/.data`、`RadarVolumeAssembler`、int16 量化) | **导入插件框架**(按文件类型注册 reader);现写死一种 |
|
||||
| 5 项目/GS/TM/ds 结构 | ds 树(`sourceShowParentId` 派生嵌套)已在 | 自动建 GS/TM 的「方案」 |
|
||||
| 6 处理插件 + 两内置 | 两内置**算法都有**:插值=`createRadarVolumeGrid` 通道插值(targetDy);预渲染=`ChunkedVolumeStore::write`+`buildPyramidStreaming`。增益(dewow/AGC/tpow)亦有 | **处理插件框架** + 「处理→存为新 ds」管线 + 多方法勾选 UI |
|
||||
| 7 选 ds 渲染/切片 | **渲染源抽象 `IVolumeRenderSource`(整卷/LOD 多态,含 `sliceSource()`)** + 整卷渲染 + 切片 + 异常 | 把 app 雷达路径迁到 `IVolumeRenderSource`;LOD 源接进 app(Track D) |
|
||||
|
||||
## 4. 架构缝:`IVolumeRenderSource`(已设计好,最低风险)
|
||||
|
||||
POC 已建好渲染源抽象(`src/render/source/IVolumeRenderSource.hpp`):上层(控制器/`SliceTool`)只认此接口,
|
||||
运行时在两种实现间切换:
|
||||
|
||||
- **`WholeVolumeSource`(整卷)** —— 给**未预渲染**的原/插值 ds(小体,单纹理够用)。
|
||||
- **`ViewAdaptiveVolumeSource`(核外金字塔 LOD)** —— 给**已预渲染**的 ds(大体,按相机选层/选块重组单纹理)。
|
||||
|
||||
接口自带 `update(vtkCamera)`、`currentImages()`、**`sliceSource()`**(切片/异常的 reslice 基底也走它),
|
||||
故"切片在两种源上都能切"是接口内建能力,不需两套切片代码。
|
||||
|
||||
> **D1 落到这里**:选 ds 渲染时按"该 ds 是否带 LOD store"路由到对应源。未预渲染 → 整卷源(现有内存
|
||||
> 体路径迁入即可);已预渲染 → LOD 源(Track D 接入)。
|
||||
|
||||
## 5. 处理与数据血缘模型
|
||||
|
||||
- 处理一律**产出新 ds**,挂在源 ds 下(复用现有派生树 `sourceShowParentId`):
|
||||
```
|
||||
原始 ds ─[插值]→ 插值 ds ─[预渲染]→ 预渲染 ds(LOD store)
|
||||
└─[增益/migration/…(可多选)]→ 处理 ds
|
||||
```
|
||||
- **两个内置处理方法**(client 自带、固定加入):
|
||||
- **插值**:线内通道插值(读真实道偏移、目标横向间距如 2.5cm,**绝不跨线**)。算法=`createRadarVolumeGrid` 的 targetDy 路径。
|
||||
- **预渲染(LOD 烘焙)**:把体烘成 **`ChunkedVolumeStore` 分块金字塔**(int16 量化、64³ brick、qCompress、
|
||||
逐级 2× 降采样、每块 min/max;流式 `buildPyramidStreaming` 不持整卷)。产出 = 一个 **store 目录**,
|
||||
不是普通稠密体 → 该 ds 须带「类型=LOD store + 路径」标记,供 §4 渲染路由。
|
||||
- 顺序:通常先插值再预渲染(烘焙插值后的体);模型支持任选基底(也可直接烘原始)。
|
||||
|
||||
## 6. 预渲染专用落盘格式(LOD 前置,已实现于 POC)
|
||||
|
||||
`ChunkedVolumeStore`(一个目录,非单文件):
|
||||
- `meta.json`:几何 + 量化(scale/offset) + 逐块索引(offset/压缩长/**每块 min/max**);
|
||||
- `data.bin`:逐块 int16 → qCompress;块内 i 最快、k 最慢;偏移全 64 位(卷 >2GB);
|
||||
- `data_L1.bin…`:金字塔各级(逐级 2× 降采样)。
|
||||
|
||||
构建:整卷 `write` 或流式 `StreamingVolumeWriter`(逐块写不持整卷)+ `buildPyramid(Streaming)`。
|
||||
渲染:`ViewAdaptiveVolumeSource` 打开 store,`update(相机)`→选层+选块→`readBrick`→重组单 `vtkImageData`,
|
||||
**内存恒定、绕 16384 纹理墙**。
|
||||
|
||||
## 7. 需要新建的三块骨架
|
||||
|
||||
1. **插件框架(两类,别混)**
|
||||
- **导入插件**(步4):按文件类型/型号 → 标准化成 ds 的 reader 注册表。
|
||||
- **处理插件**(步6):吃一个 ds → 产出新 ds 的 transform,可多选串联;两内置(插值、预渲染)即自带处理插件。
|
||||
- 待定:插件接口(输入 ds/参数 → 输出 ds)、发现/注册、进程内 DLL(ABI/崩溃隔离风险)vs 子进程。
|
||||
2. **「处理 → 新 ds」管线**:血缘落树、预渲染 ds 的 store 路径/缓存/失效/磁盘占用、重处理**异步+进度+可取消**。
|
||||
3. **设备/USB 接入**(步1–2):Windows 设备识别 + USB 盘浏览。最独立、与 POC 无关,可最后做;先跑通文件夹导入。
|
||||
|
||||
## 8. 风险排序
|
||||
|
||||
1. **中**:插件框架(架构骨架,影响步4/6,定义不好后面返工)。
|
||||
2. **中**:预渲染 ds 的渲染/切片路由(Track D 核心;但**引擎+缝已验证**,是"接线"风险非"能不能做"风险)。
|
||||
3. **低–中**:处理管线异步/进度/缓存(工程量明确)。
|
||||
4. **低**:设备 USB(plumbing,独立)。
|
||||
|
||||
## 9. 分期建议
|
||||
|
||||
- **P0 验证最高技术风险**:把 app 雷达渲染迁到 `IVolumeRenderSource`,使
|
||||
- 未预渲染 ds → `WholeVolumeSource`(迁现有内存体路径);
|
||||
- 预渲染 ds → `ViewAdaptiveVolumeSource`;
|
||||
南同大道先烘一个小 store 验"选 ds→按是否预渲染路由→渲染+切片"全链路。**验通则整个方案立住。**
|
||||
- **P1 插件框架**:先定**处理插件**接口(含两内置),跑通"原 ds→插值→预渲染→渲染";导入插件框架并行。
|
||||
- **P2 处理管线 UI/异步**:详情页多方法勾选、进度、新 ds 落树。
|
||||
- **P3 设备 USB**:最后接。
|
||||
|
||||
## 10. 现状基线(本轮已落地的交互/渲染精修,作为接入前的稳定底座)
|
||||
|
||||
- 切片拾取**精确化**:光标射线 vs 切片真实矩形求交 + 可见数据(alpha)双判定,去除外扩(雷达+反演通用)。
|
||||
- 取消选中:点击体/空白/帘面即取消(精确"命中切片"判据)+ Esc 兜底。
|
||||
- 滚轮步长:按**沿法向体素间距 × N**(Shift 粗调),不随体长跳变。
|
||||
- 双击正视:缩放到切片(按面内尺寸+视角框住),不再"又小又远"。
|
||||
- 不透明度:各向异性体用特征尺度(门控;近立方反演维持原对角线)。
|
||||
- **B 方案视角导航**:#1 绕拾取点旋转(无选中时绕光标射线穿体中段点,不甩飞);
|
||||
#2 沿线位置滑块(雷达专属,沿最长轴 dolly 到窗口;仅细长体显示)。
|
||||
- 雷达显示**增益模式**右键切换(AGC/保幅 tpow/关),纯显示重建、不动原始数据。
|
||||
|
||||
> 这些是**单内存体 + 渲染期采样距自适应**底座;多分辨率/视锥 LOD 仍属 §4/§9 的 Track D 接入范畴(未做)。
|
||||
|
|
@ -216,14 +216,22 @@ void VtkSceneView::addVolume(const std::string& dsId, const geopro::data::Volume
|
|||
volumeOwnerDs_ = dsId;
|
||||
volumes_[dsId] = VolumeRec{image, cs, vol.vmin, vol.vmax, volume}; // 多体并发:登记本体 image+actor
|
||||
|
||||
// G3 等值面:在值域高段(0.7)抽不透明实心异常体(参考图红块)。挂同一 dsProps_ → 随体一并移除。
|
||||
const double isoVal = vol.vmin + 0.7 * (vol.vmax - vol.vmin);
|
||||
auto iso = geopro::render::buildIsosurface(image, cs, vol.vmin, vol.vmax, isoVal);
|
||||
if (iso) {
|
||||
iso->PickableOff(); // 不参与拾取(同体 actor,避免串选)
|
||||
iso->SetVisibility(analysisMode2D_ ? 0 : 1); // 3D 内容:二维分析下隐藏
|
||||
scene_.addActor(iso);
|
||||
dsProps_[dsId].push_back(iso);
|
||||
// G3 等值面:在值域高段(0.7)抽不透明实心异常体(参考图红块)——【反演专属】。
|
||||
// 雷达体(registerRadarDataset 产的 "radar-" id)跳过:振幅体的 0.7 阈值面=强反射层,
|
||||
// 既无地球物理含义、又是 SetOpacity(1.0) 实色 actor【不受体不透明度控制】(用户实测:
|
||||
// 体不透明度调 0 仍见灰色实面=就是它)。"radar-" 是该体唯一生产者指定的稳定 id。
|
||||
// 注:impulse-GPR("vol-")同为振幅体、亦不该有等值面,但 "vol-" 与反演共用前缀,
|
||||
// 待 ddCode 贯通 addVolume 后再统一按类型门控(见 spec §11)。
|
||||
const bool isRadarVolume = dsId.rfind("radar-", 0) == 0;
|
||||
if (!isRadarVolume) {
|
||||
const double isoVal = vol.vmin + 0.7 * (vol.vmax - vol.vmin);
|
||||
auto iso = geopro::render::buildIsosurface(image, cs, vol.vmin, vol.vmax, isoVal);
|
||||
if (iso) {
|
||||
iso->PickableOff(); // 不参与拾取(同体 actor,避免串选)
|
||||
iso->SetVisibility(analysisMode2D_ ? 0 : 1); // 3D 内容:二维分析下隐藏
|
||||
scene_.addActor(iso);
|
||||
dsProps_[dsId].push_back(iso);
|
||||
}
|
||||
}
|
||||
if (onVolumeChanged) onVolumeChanged();
|
||||
}
|
||||
|
|
@ -359,6 +367,39 @@ void VtkSceneView::applyCameraView(geopro::controller::ViewDir dir) {
|
|||
if (onCameraChanged) onCameraChanged(); // 相机变了 → 底图按新视锥重算覆盖
|
||||
}
|
||||
|
||||
void VtkSceneView::focusAlongLongAxis(double t, double windowFrac) {
|
||||
double b[6];
|
||||
if (!computeDataBounds(b) || !scene_.renderer()) return;
|
||||
const double ex = b[1] - b[0], ey = b[3] - b[2], ez = b[5] - b[4];
|
||||
const int ax = (ex >= ey && ex >= ez) ? 0 : (ey >= ez ? 1 : 2); // 最长轴
|
||||
const double lo = b[2 * ax], hi = b[2 * ax + 1], len = hi - lo;
|
||||
if (len <= 0.0) return;
|
||||
if (t < 0.0) t = 0.0;
|
||||
if (t > 1.0) t = 1.0;
|
||||
if (windowFrac <= 0.0) windowFrac = 0.12;
|
||||
const double half = 0.5 * windowFrac * len;
|
||||
const double center = lo + t * len;
|
||||
double sub[6] = {b[0], b[1], b[2], b[3], b[4], b[5]}; // 短轴满幅
|
||||
sub[2 * ax] = (center - half < lo) ? lo : center - half; // 长轴只取窗口段
|
||||
sub[2 * ax + 1] = (center + half > hi) ? hi : center + half;
|
||||
scene_.renderer()->ResetCamera(sub); // 保持朝向,仅重定位+缩放到该窗口
|
||||
scene_.renderer()->ResetCameraClippingRange();
|
||||
if (renderWindow_) renderWindow_->Render();
|
||||
if (onCameraChanged) onCameraChanged(); // 底图随新视锥重算
|
||||
}
|
||||
|
||||
double VtkSceneView::longAxisElongation() const {
|
||||
double b[6];
|
||||
if (!computeDataBounds(b)) return 0.0;
|
||||
const double ex = std::abs(b[1] - b[0]), ey = std::abs(b[3] - b[2]), ez = std::abs(b[5] - b[4]);
|
||||
double mx = ex, mn = ex;
|
||||
if (ey > mx) mx = ey;
|
||||
if (ez > mx) mx = ez;
|
||||
if (ey < mn) mn = ey;
|
||||
if (ez < mn) mn = ez;
|
||||
return (mn > 0.0) ? mx / mn : 0.0;
|
||||
}
|
||||
|
||||
void VtkSceneView::zoom(double factor) {
|
||||
geopro::render::zoomBy(scene_.renderer(), factor);
|
||||
if (renderWindow_) renderWindow_->Render();
|
||||
|
|
|
|||
|
|
@ -94,6 +94,13 @@ public:
|
|||
void setAnalysisMode2D(bool is2D);
|
||||
bool isAnalysisMode2D() const { return analysisMode2D_; }
|
||||
|
||||
// ── B 方案#2:沿线位置巡航(雷达超长测线)──────────────────────────────────────
|
||||
// t∈[0,1] 沿数据【最长轴】定位;取景到该位置一段【窗口】(windowFrac=窗口占长轴比例),
|
||||
// 保持当前朝向(ResetCamera 只重定位+缩放、不转向)→ 像滚动读长 radargram。短轴满幅、长轴只取一段。
|
||||
void focusAlongLongAxis(double t, double windowFrac);
|
||||
// 数据包围盒长短轴比(max/min 跨度)。用于判是否细长(雷达)→ 决定沿线滑块显隐。无数据返回 0。
|
||||
double longAxisElongation() const;
|
||||
|
||||
// ── 二维分析改造 B 期:选中 2D 足迹沿高程 Z 拖动 ───────────────────────────────
|
||||
// 仅二维分析下用。pickMapLineAt:在屏幕(x,y)拾取足迹(只考虑可见足迹,不被地形/底图干扰);命中则
|
||||
// 选中(additive=Ctrl 多选切换,否则单选替换)并高亮,返回是否有选中(交互样式据此决定 Z 拖动/平移)。
|
||||
|
|
|
|||
|
|
@ -245,6 +245,38 @@ private:
|
|||
std::vector<QWidget*> raiseAfter_; // 定位后再 raise 到 overlay 之上的常驻控件(工具条/提示)
|
||||
};
|
||||
|
||||
// 底部条浮层定位器:把 overlay 钉在 host(QVTK 画布)底部、横向铺满(留边距)。
|
||||
// 用于 B 方案#2 雷达沿线位置滑块。仅外观,无 Q_OBJECT/moc。
|
||||
class BottomBarOverlay : public QObject {
|
||||
public:
|
||||
BottomBarOverlay(QWidget* overlay, QWidget* host, int margin = 12, int barHeight = 34)
|
||||
: QObject(host), overlay_(overlay), host_(host), margin_(margin), barHeight_(barHeight)
|
||||
{
|
||||
host_->installEventFilter(this);
|
||||
}
|
||||
void reposition()
|
||||
{
|
||||
const QSize h = host_->size();
|
||||
const int w = std::max(160, h.width() - 2 * margin_);
|
||||
overlay_->resize(w, barHeight_);
|
||||
overlay_->move(margin_, std::max(0, h.height() - barHeight_ - margin_));
|
||||
if (overlay_->isVisible()) overlay_->raise(); // GL 子控件须 raise 才可见
|
||||
}
|
||||
|
||||
protected:
|
||||
bool eventFilter(QObject* obj, QEvent* e) override
|
||||
{
|
||||
if (obj == host_ && (e->type() == QEvent::Resize || e->type() == QEvent::Show)) reposition();
|
||||
return QObject::eventFilter(obj, e);
|
||||
}
|
||||
|
||||
private:
|
||||
QWidget* overlay_;
|
||||
QWidget* host_;
|
||||
int margin_;
|
||||
int barHeight_;
|
||||
};
|
||||
|
||||
// 取 vector 中位数(用于由测线 lat/lon 推世界系原点)。空则返回 0。
|
||||
double median(std::vector<double> v)
|
||||
{
|
||||
|
|
@ -471,6 +503,39 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
|
|||
elevHint->show();
|
||||
elevHint->raise();
|
||||
});
|
||||
// ── B 方案#2:雷达沿线位置滑块(超长测线巡航)────────────────────────────────────
|
||||
// 拖动 → 相机沿数据最长轴 dolly 到该位置的一段窗口(focusAlongLongAxis)。仅细长(雷达)体显示。
|
||||
auto* alongLineBar = new QWidget(vtkWidget);
|
||||
alongLineBar->setObjectName(QStringLiteral("alongLineBar"));
|
||||
auto* alongLineSlider = new QSlider(Qt::Horizontal, alongLineBar);
|
||||
{
|
||||
auto* lay = new QHBoxLayout(alongLineBar);
|
||||
lay->setContentsMargins(12, 4, 12, 4);
|
||||
lay->setSpacing(10);
|
||||
auto* lbl = new QLabel(QStringLiteral("沿线位置"), alongLineBar);
|
||||
lbl->setObjectName(QStringLiteral("alongLineLbl"));
|
||||
alongLineSlider->setRange(0, 1000);
|
||||
alongLineSlider->setValue(0);
|
||||
lay->addWidget(lbl);
|
||||
lay->addWidget(alongLineSlider, 1);
|
||||
}
|
||||
geopro::app::applyTokenizedStyleSheet(
|
||||
alongLineBar,
|
||||
QStringLiteral("QWidget#alongLineBar{background:#0E1A2D;"
|
||||
"border:1px solid {{border/default}};border-radius:8px;}"
|
||||
"QLabel#alongLineLbl{color:#E6ECF5;border:none;}"));
|
||||
alongLineBar->hide(); // 默认隐藏,仅细长(雷达)体显示
|
||||
auto* alongLineOverlay = new BottomBarOverlay(alongLineBar, vtkWidget);
|
||||
QObject::connect(alongLineSlider, &QSlider::valueChanged, vtkWidget,
|
||||
[sceneView](int v) { sceneView->focusAlongLongAxis(v / 1000.0, 0.12); });
|
||||
// 显隐刷新:仅三维分析 + 细长(长短轴比≥4,即雷达)体时显示沿线滑块。
|
||||
auto refreshAlongLineBar = std::make_shared<std::function<void()>>(
|
||||
[sceneView, alongLineBar, alongLineOverlay]() {
|
||||
const bool show = !sceneView->isAnalysisMode2D() && sceneView->longAxisElongation() >= 4.0;
|
||||
alongLineBar->setVisible(show);
|
||||
if (show) alongLineOverlay->reposition();
|
||||
});
|
||||
|
||||
if (auto* style = interactionMgr->pickStyle()) {
|
||||
// 命中可见足迹→选中(Ctrl 多选)并返回是否进入 Z 拖动;未命中(返回 false)→交互样式回退平移。
|
||||
style->onPick2D = [sceneView](int x, int y, bool additive) {
|
||||
|
|
@ -567,7 +632,8 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
|
|||
};
|
||||
|
||||
// 体素变化(重建/清场)后把体素 image 推给 InteractionManager(切片基底),并调和已保存切片 + 异常。
|
||||
sceneView->onVolumeChanged = [interactionMgr, sceneView, syncSlices, refreshAnomalies]() {
|
||||
sceneView->onVolumeChanged = [interactionMgr, sceneView, syncSlices, refreshAnomalies,
|
||||
refreshAlongLineBar]() {
|
||||
// 多体并发:先移除 interactionMgr 中已不再渲染的体(关其切片),再 upsert 当前所有已渲染体 image。
|
||||
for (const std::string& id : interactionMgr->volumeIds())
|
||||
if (!sceneView->isVolumeRendered(id)) interactionMgr->removeVolumeImage(id);
|
||||
|
|
@ -576,6 +642,7 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
|
|||
kv.second.vmin, kv.second.vmax);
|
||||
syncSlices(); // 体到场/移除后调和各体下已勾选切片(多体并存)
|
||||
refreshAnomalies(); // 同步重载异常 actor + 刷新异常列表
|
||||
(*refreshAlongLineBar)(); // 体增删 → 据是否细长(雷达)刷新沿线滑块显隐(B#2)
|
||||
};
|
||||
|
||||
// ── 抽屉信号 → 控制器/交互(Task 7/12 接线)──────────────────────────────
|
||||
|
|
@ -1001,9 +1068,11 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
|
|||
QStringLiteral("输入测线前缀(如 南同大道_000):"), QLineEdit::Normal, QString(), &ok);
|
||||
if (!ok || prefix.isEmpty()) return;
|
||||
// structParentId 暂空(P0 挂三维体段根;P1 接 TM 归属)。
|
||||
// coarse=1 全分辨率(沿线不抽稀):验收期要肉眼判读反射/双曲线/通道连续性,
|
||||
// 不能被沿线抽稀糊掉。单线峰值内存 ~0.7–1.5GB(spec §8.4);若 OOM 退回 2。
|
||||
const std::string newId = scene3dRepo->registerRadarDataset(
|
||||
dir.toLocal8Bit().toStdString(), prefix.toLocal8Bit().toStdString(),
|
||||
prefix.toStdString(), /*structParentId=*/std::string(), /*coarse=*/4);
|
||||
prefix.toStdString(), /*structParentId=*/std::string(), /*coarse=*/1);
|
||||
{ const QSignalBlocker block(analysisTab); refreshAnalysis(); } // DS 进三维体段(不触发渲染)
|
||||
const QString qid = QString::fromStdString(newId);
|
||||
analysisTab->setItemChecked(qid, true); // 勾选 → addDatasetAsync → loadVolume 后台建体渲染
|
||||
|
|
@ -1196,6 +1265,17 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
|
|||
QMessageBox::warning(&window, QStringLiteral("导出"),
|
||||
QStringLiteral("导出失败。"));
|
||||
});
|
||||
// 雷达体显示增益模式切换(右键「增益模式」):仓储切模式+清缓存 → 控制器清缓存/旧色阶并(勾选中)
|
||||
// 异步用新增益重建体重渲。0=关(原始) 1=AGC(显深部) 2=保幅 tpow(判振幅)。纯显示,不动原始 .data。
|
||||
QObject::connect(
|
||||
analysisTab, &geopro::app::CategoryAnalysisTab::radarGainModeRequested, &window,
|
||||
[scene3dRepo, sceneCtrl](const QString& qid, int mode) {
|
||||
const auto m = (mode == 0) ? geopro::data::RadarGainMode::Off
|
||||
: (mode == 2) ? geopro::data::RadarGainMode::Tpow
|
||||
: geopro::data::RadarGainMode::Agc;
|
||||
if (scene3dRepo->setRadarGainMode(qid.toStdString(), m))
|
||||
sceneCtrl->rebuildRadarVolume(qid.toStdString());
|
||||
});
|
||||
// 色阶(三维体/切片):复刻原版「色阶配置」对话框,确定后体素 + 其切片随新色阶重渲染。
|
||||
// 仅对当前已渲染的三维体生效(切片色阶继承体色阶,经 InteractionManager 重建)。
|
||||
QObject::connect(analysisTab, &geopro::app::CategoryAnalysisTab::colorScaleRequested, &window,
|
||||
|
|
@ -1340,11 +1420,12 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
|
|||
// 渲染 [VtkSceneView]。顺序:先 ①②(都不渲染),最后 ③ 收尾统一渲染。只翻可见标志、不清空/重建 →
|
||||
// 切换瞬时;地形+底图常驻。
|
||||
QObject::connect(drawer, &geopro::app::ColumnDrawer::analysisModeChanged, &window,
|
||||
[interactionMgr, sceneCtrl, sceneView, viewToolbar](bool is2D) {
|
||||
[interactionMgr, sceneCtrl, sceneView, viewToolbar, refreshAlongLineBar](bool is2D) {
|
||||
interactionMgr->setMode2D(is2D);
|
||||
sceneCtrl->onAnalysisModeChanged(is2D);
|
||||
sceneView->setAnalysisMode2D(is2D);
|
||||
viewToolbar->setAnalysisMode2D(is2D); // 二维下禁用 6 向快捷视图
|
||||
(*refreshAlongLineBar)(); // 二维隐藏沿线滑块、三维细长体显示(B#2)
|
||||
});
|
||||
|
||||
// 首个真实剖面到达 → frame 重锚到数据 lat/lon 后,把选中的底图加载到数据所在位置
|
||||
|
|
@ -1436,7 +1517,7 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
|
|||
|
||||
auto* emptyCentering = new CenterOverlay(emptyState, vtkWidget);
|
||||
// 引导层定位后,把工具条与提示浮层 raise 到其上 → 工具条永在最上层(修:缩小渲染区时引导层挡工具条)。
|
||||
emptyCentering->setRaiseAfter({viewToolbar, anomalyHint, elevHint});
|
||||
emptyCentering->setRaiseAfter({viewToolbar, anomalyHint, elevHint, alongLineBar});
|
||||
emptyCentering->reposition();
|
||||
// 引导层隐藏器就位(见 pushChecked 处声明):场景(剖面∪三维分析)有勾选 → 隐藏不透明引导层、露出渲染。
|
||||
*setSceneEmptyVisible = [emptyState](bool empty) { emptyState->setVisible(empty); };
|
||||
|
|
|
|||
|
|
@ -54,6 +54,8 @@ CategoryAnalysisTab::CategoryAnalysisTab(geopro::data::DatasetFieldDictionary* d
|
|||
&CategoryAnalysisTab::deleteDatasetRequested);
|
||||
connect(sec, &CategorySection::sliceRequested, this, &CategoryAnalysisTab::sliceRequested);
|
||||
connect(sec, &CategorySection::colorScaleRequested, this, &CategoryAnalysisTab::colorScaleRequested);
|
||||
connect(sec, &CategorySection::radarGainModeRequested, this,
|
||||
&CategoryAnalysisTab::radarGainModeRequested);
|
||||
connect(sec, &CategorySection::sliceSaveRequested, this, &CategoryAnalysisTab::sliceSaveRequested);
|
||||
connect(sec, &CategorySection::sliceSaveAsRequested, this, &CategoryAnalysisTab::sliceSaveAsRequested);
|
||||
connect(sec, &CategorySection::sliceExportImageRequested, this,
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ signals:
|
|||
// ── 三维体段操作转发(迁自旧 Column3DAnalysis,全接)──
|
||||
void sliceRequested(geopro::render::interact::SliceAxis axis, const QString& volumeDsId);
|
||||
void colorScaleRequested(const QString& dsId);
|
||||
void radarGainModeRequested(const QString& dsId, int mode); // 雷达体显示增益模式(0关/1AGC/2保幅)
|
||||
void sliceSaveRequested(const QString& dsId);
|
||||
void sliceSaveAsRequested(const QString& dsId);
|
||||
void sliceExportImageRequested(const QString& dsId);
|
||||
|
|
|
|||
|
|
@ -427,6 +427,15 @@ void CategorySection::showContextMenu(const QPoint& pos) {
|
|||
sl->addAction(QStringLiteral("左右"), this, [this, id] { emit sliceRequested(SliceAxis::LeftRight, id); });
|
||||
sl->addAction(QStringLiteral("任意"), this, [this, id] { emit sliceRequested(SliceAxis::Oblique, id); });
|
||||
menu.addAction(QStringLiteral("色阶"), this, [this, id] { emit colorScaleRequested(id); });
|
||||
if (ddCode == QStringLiteral("dd_radar_3d")) { // 雷达体:显示增益模式切换(纯显示,重建体)
|
||||
QMenu* gm = menu.addMenu(QStringLiteral("增益模式"));
|
||||
gm->addAction(QStringLiteral("AGC(显深部·找目标)"), this,
|
||||
[this, id] { emit radarGainModeRequested(id, 1); });
|
||||
gm->addAction(QStringLiteral("保幅 tpow(判振幅·复核)"), this,
|
||||
[this, id] { emit radarGainModeRequested(id, 2); });
|
||||
gm->addAction(QStringLiteral("关(原始振幅)"), this,
|
||||
[this, id] { emit radarGainModeRequested(id, 0); });
|
||||
}
|
||||
} else if (ddCode == QStringLiteral("dd_slice")) { // 切片(列表中均为已保存=定稿锁定,无保存/另存)
|
||||
QMenu* ex = menu.addMenu(QStringLiteral("导出"));
|
||||
ex->addAction(QStringLiteral("图片"), this, [this, id] { emit sliceExportImageRequested(id); });
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@ signals:
|
|||
// ── 三维体段右键操作(迁自旧 Column3DAnalysis,全接)──
|
||||
void sliceRequested(geopro::render::interact::SliceAxis axis, const QString& volumeDsId); // 体→生成切片(轴+目标体)
|
||||
void colorScaleRequested(const QString& dsId); // 体/切片→色阶
|
||||
void radarGainModeRequested(const QString& dsId, int mode); // 雷达体→显示增益模式(0关/1AGC/2保幅tpow)
|
||||
void sliceSaveRequested(const QString& dsId); // 切片→保存位姿
|
||||
void sliceSaveAsRequested(const QString& dsId); // 切片→另存
|
||||
void sliceExportImageRequested(const QString& dsId); // 切片→导出图片
|
||||
|
|
|
|||
|
|
@ -39,6 +39,10 @@ public:
|
|||
if (!scales_.contains(dsId)) scales_.insert(dsId, cs);
|
||||
}
|
||||
|
||||
// 清除某 ds 的色阶真源(不广播):体被以新参数重建(如雷达切增益模式→值域大变)时调,
|
||||
// 让下次 seedColorScale 用重建后的新色阶,而非沿用旧窗口。
|
||||
void clearColorScale(const QString& dsId) { scales_.remove(dsId); }
|
||||
|
||||
signals:
|
||||
void colorScaleChanged(const QString& dsId);
|
||||
|
||||
|
|
|
|||
|
|
@ -323,6 +323,18 @@ void VtkSceneController::setVolumeColorScale(const std::string& dsId,
|
|||
view_.renderIncremental();
|
||||
}
|
||||
|
||||
void VtkSceneController::rebuildRadarVolume(const std::string& dsId) {
|
||||
// 仓储已切增益模式并失效其 cachedGrid(setRadarGainMode)。此处:
|
||||
// 1) 清控制器缓存(否则命中旧体);2) 清旧色阶真源(增益后值域大变,须用重建后新窗口);
|
||||
// 3) 移除旧 actor;4) 若勾选中 → 异步用新增益重建体并重渲。
|
||||
volumeCache_.erase(dsId);
|
||||
volumeScaleCache_.erase(dsId);
|
||||
if (state_) state_->clearColorScale(QString::fromStdString(dsId));
|
||||
view_.removeDataset(dsId);
|
||||
if (isChecked(dsId)) addDatasetAsync(dsId, rebuildGeneration_);
|
||||
view_.renderIncremental();
|
||||
}
|
||||
|
||||
void VtkSceneController::setAxesMode(AxesMode mode) {
|
||||
axesMode_ = mode;
|
||||
rebuildInternal(); // 坐标轴随场景重建(clear 会移除旧坐标轴 prop)
|
||||
|
|
|
|||
|
|
@ -57,6 +57,9 @@ public slots:
|
|||
// 三维体透明度调节(工具条滑块):运行时更新已渲染体的不透明度,并作为后续新体默认(0~1)。
|
||||
void setVolumeOpacity(double maxOpacity);
|
||||
void rebuild(); // 主题切换等外部触发的重渲染
|
||||
// 雷达体增益模式切换后重建:仓储已切模式+清缓存(setRadarGainMode),此处清控制器缓存/旧色阶
|
||||
// 并(若勾选中)异步用新增益重建体、重渲。
|
||||
void rebuildRadarVolume(const std::string& dsId);
|
||||
|
||||
// 色阶编辑器「确定」:写入色阶真源(state_),经 colorScaleChanged 统一就地重着色(体/帘面 + 切片)。
|
||||
// 兼容旧调用点;真正的重着色在 recolorDataset()。无 state_ 时退化为直连重建。
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
#include "data/GprVolumeRepository.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
#include <limits>
|
||||
#include <vector>
|
||||
|
||||
#include "core/model/ScalarVolumeI16.hpp"
|
||||
#include "io/gpr/Gpr3dvVolumeBridge.hpp"
|
||||
|
|
@ -18,20 +21,63 @@ VolumeGrid builtI16ToVolumeGrid(const geopro::core::BuiltI16& built) {
|
|||
out.vol = geopro::core::ScalarVolume(nx, ny, nz);
|
||||
out.origin = built.origin;
|
||||
out.spacing = built.spacing;
|
||||
out.vmin = built.vminPhys;
|
||||
out.vmax = built.vmaxPhys;
|
||||
|
||||
// 逐体素反量化(布局一致:i 最快、k 最慢)。
|
||||
// 逐体素反量化(布局一致:i 最快、k 最慢) + 同遍累计 int16 直方图(零额外遍历)。
|
||||
// kBlank → NaN:下游 render::buildVoxel 把 NaN 映射到 [vmin,vmax] 外的哨兵 →
|
||||
// 传递函数置全透明(与 float 路径空值语义一致)。
|
||||
// 传递函数置全透明(与 float 路径空值语义一致)。GPR 稠密体无 kBlank,直方图即全体素。
|
||||
const std::vector<std::int16_t>& src = built.vol.data();
|
||||
std::vector<double>& dst = out.vol.data();
|
||||
const double nan = std::numeric_limits<double>::quiet_NaN();
|
||||
constexpr int kHistN = 65536; // int16 全域,桶 b ↔ 量化值 q = b - 32768
|
||||
std::vector<std::uint32_t> hist(kHistN, 0);
|
||||
std::uint64_t total = 0;
|
||||
for (std::size_t idx = 0; idx < src.size(); ++idx) {
|
||||
const std::int16_t q = src[idx];
|
||||
dst[idx] = (q == geopro::core::ScalarVolumeI16::kBlank)
|
||||
? nan
|
||||
: built.quant.toPhys(q);
|
||||
if (q == geopro::core::ScalarVolumeI16::kBlank) {
|
||||
dst[idx] = nan;
|
||||
continue;
|
||||
}
|
||||
dst[idx] = built.quant.toPhys(q);
|
||||
++hist[static_cast<int>(q) + 32768];
|
||||
++total;
|
||||
}
|
||||
|
||||
// 显示值域 = 【双极对称】99% 窗口(GPR 标准 B-scan),而非全 min/max、也非非对称分位。
|
||||
// GPR 振幅正负振荡:基线(零反射)应=中灰、强负=黑、强正=白,且窗口对称否则色调失衡。
|
||||
// 做法:以【中位数=基线】为中心向两侧等距扩张,直到覆盖 99% 样本 → ±A。强反射/饱和值
|
||||
// (原始数据实测有 int16 下限 -32768 的钳值)落在 1% 尾外 → clamp 到端色,结构铺开如实显示。
|
||||
// 对齐独立 Python radargram(双极对称 ±p99);用全值域=灰板,用非对称 2/98=过饱和+灰点偏移。
|
||||
// 退化(全同值)兜底回全值域。
|
||||
out.vmin = built.vminPhys;
|
||||
out.vmax = built.vmaxPhys;
|
||||
if (total > 0) {
|
||||
// 中位数桶(基线≈零振幅)。
|
||||
int centerQ = 32767;
|
||||
{
|
||||
const double half = 0.5 * static_cast<double>(total);
|
||||
std::uint64_t cum = 0;
|
||||
for (int b = 0; b < kHistN; ++b) {
|
||||
cum += hist[b];
|
||||
if (static_cast<double>(cum) > half) { centerQ = b - 32768; break; }
|
||||
}
|
||||
}
|
||||
// 自中位数对称外扩,覆盖 99% → 半宽 A。
|
||||
const double need = 0.99 * static_cast<double>(total);
|
||||
const int ci = centerQ + 32768;
|
||||
std::uint64_t cum = (ci >= 0 && ci < kHistN) ? hist[ci] : 0;
|
||||
int A = 0;
|
||||
for (int r = 1; r < kHistN; ++r) {
|
||||
const int lo = ci - r, hi = ci + r;
|
||||
if (lo >= 0 && lo < kHistN) cum += hist[lo];
|
||||
if (hi >= 0 && hi < kHistN) cum += hist[hi];
|
||||
if (static_cast<double>(cum) >= need) { A = r; break; }
|
||||
}
|
||||
if (A > 0) {
|
||||
const int loQ = std::max(-32768, centerQ - A);
|
||||
const int hiQ = std::min(32767, centerQ + A);
|
||||
out.vmin = built.quant.toPhys(static_cast<std::int16_t>(loQ));
|
||||
out.vmax = built.quant.toPhys(static_cast<std::int16_t>(hiQ));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
|
@ -48,15 +94,83 @@ VolumeGrid createGprVolumeGrid(const std::string& lineDir,
|
|||
return builtI16ToVolumeGrid(built);
|
||||
}
|
||||
|
||||
namespace {
|
||||
// 单道(沿深度 n)增益:先 dewow(减滑动均值去低频 DC),再按模式 AGC(除滑窗 RMS,显弱反射但抹相对幅)
|
||||
// 或 Tpow(×(k+1)^幂,保幅补衰减)。前缀和 O(n)。
|
||||
void gainTraceInPlace(double* col, int n, RadarGainMode mode, int dewowWin, int agcWin,
|
||||
double tpowPower) {
|
||||
if (n <= 0 || mode == RadarGainMode::Off) return;
|
||||
if (dewowWin > 1) {
|
||||
const int h = dewowWin / 2;
|
||||
std::vector<double> ps(n + 1, 0.0);
|
||||
for (int k = 0; k < n; ++k) ps[k + 1] = ps[k] + col[k];
|
||||
std::vector<double> o(n);
|
||||
for (int k = 0; k < n; ++k) {
|
||||
const int lo = std::max(0, k - h), hi = std::min(n, k + h + 1);
|
||||
o[k] = col[k] - (ps[hi] - ps[lo]) / (hi - lo);
|
||||
}
|
||||
for (int k = 0; k < n; ++k) col[k] = o[k];
|
||||
}
|
||||
if (mode == RadarGainMode::Agc && agcWin > 1) {
|
||||
const int h = agcWin / 2;
|
||||
std::vector<double> ps2(n + 1, 0.0);
|
||||
for (int k = 0; k < n; ++k) ps2[k + 1] = ps2[k] + col[k] * col[k];
|
||||
for (int k = 0; k < n; ++k) {
|
||||
const int lo = std::max(0, k - h), hi = std::min(n, k + h + 1);
|
||||
const double rms = std::sqrt((ps2[hi] - ps2[lo]) / (hi - lo)) + 1e-6;
|
||||
col[k] /= rms;
|
||||
}
|
||||
} else if (mode == RadarGainMode::Tpow && tpowPower > 0.0) {
|
||||
for (int k = 0; k < n; ++k)
|
||||
col[k] *= std::pow(static_cast<double>(k + 1), tpowPower); // 保幅:随深度放大、不抹相对强弱
|
||||
}
|
||||
}
|
||||
|
||||
// 逐道显示增益 + 增益后重取双极对称 99% 窗口。纯显示:只改 app 渲染体 g.vol,原始 .data 不变。
|
||||
void applyRadarDisplayGain(VolumeGrid& g, RadarGainMode mode, int dewowWin, int agcWin,
|
||||
double tpowPower) {
|
||||
const int nx = g.vol.nx(), ny = g.vol.ny(), nz = g.vol.nz();
|
||||
if (nx <= 0 || ny <= 0 || nz <= 0) return;
|
||||
std::vector<double>& d = g.vol.data();
|
||||
auto idx = [nx, ny](int i, int j, int k) {
|
||||
return (static_cast<std::size_t>(k) * ny + j) * nx + i;
|
||||
};
|
||||
std::vector<double> col(nz);
|
||||
for (int j = 0; j < ny; ++j)
|
||||
for (int i = 0; i < nx; ++i) {
|
||||
for (int k = 0; k < nz; ++k) col[k] = d[idx(i, j, k)];
|
||||
gainTraceInPlace(col.data(), nz, mode, dewowWin, agcWin, tpowPower);
|
||||
for (int k = 0; k < nz; ++k) d[idx(i, j, k)] = col[k];
|
||||
}
|
||||
// 增益后重取窗口(中位数中心、对称 99%),否则旧窗口与增益后量纲不符。
|
||||
std::vector<double> a(d.begin(), d.end());
|
||||
if (!a.empty()) {
|
||||
const std::size_t m = a.size() / 2;
|
||||
std::nth_element(a.begin(), a.begin() + m, a.end());
|
||||
const double med = a[m];
|
||||
for (double& x : a) x = std::abs(x - med);
|
||||
const std::size_t q = static_cast<std::size_t>(0.99 * (a.size() - 1));
|
||||
std::nth_element(a.begin(), a.begin() + q, a.end());
|
||||
const double A = a[q] > 0.0 ? a[q] : 1.0;
|
||||
g.vmin = med - A;
|
||||
g.vmax = med + A;
|
||||
}
|
||||
}
|
||||
} // namespace
|
||||
|
||||
VolumeGrid createRadarVolumeGrid(const std::string& lineDir,
|
||||
const std::string& linePrefix, int coarse,
|
||||
double targetDy) {
|
||||
double targetDy, RadarGainMode gainMode) {
|
||||
// 走规范化测线链(io::gpr) 读 .head/.data → int16 量化体 → 反量化为 app 的 float 体。
|
||||
// 与 createGprVolumeGrid 同适配器(builtI16ToVolumeGrid),仅上游数据源不同。
|
||||
const geopro::core::BuiltI16 built =
|
||||
geopro::io::gpr::buildLineVolumeFromNormalized(lineDir, linePrefix, coarse,
|
||||
targetDy);
|
||||
return builtI16ToVolumeGrid(built);
|
||||
VolumeGrid g = builtI16ToVolumeGrid(built);
|
||||
// 显示增益(纯显示):Agc 显深部弱反射 / Tpow 保幅。原始数据不变;窗口随增益重取。
|
||||
if (gainMode != RadarGainMode::Off)
|
||||
applyRadarDisplayGain(g, gainMode, /*dewowWin=*/30, /*agcWin=*/50, /*tpowPower=*/1.5);
|
||||
return g;
|
||||
}
|
||||
|
||||
} // namespace geopro::data
|
||||
|
|
|
|||
|
|
@ -40,9 +40,17 @@ VolumeGrid createGprVolumeGrid(const std::string& lineDir,
|
|||
// 上游数据源走规范化 .head/.data 而非 .iprh/.iprb。
|
||||
// coarse(≥1)沿测线下采样;targetDy(米,>0 启用)线内通道插值(读 .head CH_X_OFFSETS)。
|
||||
// 失败(加载失败/解析失败)→ 抛 std::runtime_error(由 io::gpr 链抛出,原样透传)。
|
||||
// 雷达显示增益模式(纯显示、逐道沿深度、不动原始 .data;窗口随增益重取):
|
||||
// Off = 原始振幅;
|
||||
// Agc = dewow + 滑窗 RMS 归一:最大化显出深部弱反射(找目标),但【抹相对振幅】;
|
||||
// Tpow = dewow + ×时间^幂:保幅增益(补几何扩散/衰减),【保留相对强弱】——判振幅/复核异常用。
|
||||
// (审阅者点③:标深部目标前应在 Tpow 保幅下也确认,不只信 Agc。)
|
||||
enum class RadarGainMode { Off, Agc, Tpow };
|
||||
|
||||
VolumeGrid createRadarVolumeGrid(const std::string& lineDir,
|
||||
const std::string& linePrefix, int coarse = 4,
|
||||
double targetDy = 0.025);
|
||||
double targetDy = 0.025,
|
||||
RadarGainMode gainMode = RadarGainMode::Off);
|
||||
|
||||
} // namespace geopro::data
|
||||
|
||||
|
|
|
|||
|
|
@ -29,6 +29,22 @@ namespace geopro::data {
|
|||
|
||||
namespace {
|
||||
constexpr const char* kNotReady = "后端三维端点未就绪";
|
||||
|
||||
// 雷达中性灰度色阶。⚠ core::ColorScale 是【阶梯/分段常数】(colorAt 取下界 stop,不插值):
|
||||
// 只放 3 个 stop(黑/灰/白) → 整个 [mid,vmax) 正振幅全塌成一级灰、[vmin,mid) 全黑,连续 GPR
|
||||
// 数据被压成 3 级(深部 DC 渲成恒灰、丢层理)——实测确诊的真 bug。故铺 256 级平滑斜坡
|
||||
// black→white,colorAt 才能给出连续灰阶。(反演等值面仍用稀疏 stop 的阶梯,故意离散,不动。)
|
||||
core::ColorScale radarGrayScale(double vmin, double vmax) {
|
||||
core::ColorScale cs;
|
||||
constexpr int kLevels = 256;
|
||||
for (int i = 0; i < kLevels; ++i) {
|
||||
const double f = static_cast<double>(i) / (kLevels - 1); // 0..1
|
||||
const double val = vmin + (vmax - vmin) * f;
|
||||
const auto g = static_cast<unsigned char>(std::lround(f * 255.0));
|
||||
cs.addStop(val, core::Rgba{g, g, g, 255});
|
||||
}
|
||||
return cs;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
Api3dRepository::Api3dRepository(IAsyncDatasetRepository& dsRepo,
|
||||
|
|
@ -130,12 +146,8 @@ std::string Api3dRepository::createGprVolume(const std::string& lineDir,
|
|||
const std::string& name, int coarse) {
|
||||
// 走 io::gpr 逐线管线(含线内通道插值)直接产体(抛异常透传给调用方)。
|
||||
VolumeGrid grid = geopro::data::createGprVolumeGrid(lineDir, linePrefix, coarse);
|
||||
// 简易灰度色阶(负→暗、零→灰、正→亮)覆盖体值域,使体素渲染可见。
|
||||
core::ColorScale scale;
|
||||
const double mid = 0.5 * (grid.vmin + grid.vmax);
|
||||
scale.addStop(grid.vmin, core::Rgba{20, 24, 40, 255});
|
||||
scale.addStop(mid, core::Rgba{140, 140, 150, 255});
|
||||
scale.addStop(grid.vmax, core::Rgba{235, 232, 220, 255});
|
||||
// 中性灰度(GPR 标准 B-scan),256 级连续——3-stop 会被 colorAt 阶梯压成 3 级(见 radarGrayScale)。
|
||||
const core::ColorScale scale = radarGrayScale(grid.vmin, grid.vmax);
|
||||
|
||||
const std::string id = "vol-" + std::to_string(++volumeCounter_);
|
||||
StoredVolume sv;
|
||||
|
|
@ -167,6 +179,15 @@ std::string Api3dRepository::registerRadarDataset(const std::string& lineDir,
|
|||
return id;
|
||||
}
|
||||
|
||||
bool Api3dRepository::setRadarGainMode(const std::string& dsId, RadarGainMode mode) {
|
||||
auto it = volumes_.find(dsId);
|
||||
if (it == volumes_.end() || it->second.ddCode != "dd_radar_3d") return false;
|
||||
if (it->second.gainMode == mode) return true; // 未变化也算成功(调用方可跳过重渲)
|
||||
it->second.gainMode = mode;
|
||||
it->second.cachedGrid.reset(); // 失效缓存体 → 下次 loadVolume 用新增益模式重建
|
||||
return true;
|
||||
}
|
||||
|
||||
const VoxelGenerateRequest* Api3dRepository::lastVoxelRequest(const std::string& dsId) const {
|
||||
const auto it = volumes_.find(dsId);
|
||||
return (it != volumes_.end() && it->second.request) ? &*it->second.request : nullptr;
|
||||
|
|
@ -372,28 +393,26 @@ void Api3dRepository::loadVolume(const std::string& dsId,
|
|||
if (!sv.linePrefix.empty()) { // 雷达体 DS:后台建体,避免阻塞 UI(与 finalizeVolume 同范式)
|
||||
const std::string lineDir = sv.lineDir, linePrefix = sv.linePrefix;
|
||||
const int coarse = sv.coarse;
|
||||
const RadarGainMode gainMode = sv.gainMode; // 显示增益模式(右键可切,切时清缓存重建)
|
||||
auto deliver = [this, dsId, onOk, onErr](std::shared_ptr<VolumeGrid> g, std::string err) {
|
||||
if (!g) {
|
||||
onErr("Api3dRepository::loadVolume(radar): " + err);
|
||||
return;
|
||||
}
|
||||
core::ColorScale scale; // 简易灰度色阶(负→暗、零→灰、正→亮),使体素渲染可见。
|
||||
const double mid = 0.5 * (g->vmin + g->vmax);
|
||||
scale.addStop(g->vmin, core::Rgba{20, 24, 40, 255});
|
||||
scale.addStop(mid, core::Rgba{140, 140, 150, 255});
|
||||
scale.addStop(g->vmax, core::Rgba{235, 232, 220, 255});
|
||||
// 中性灰度,256 级连续(见 radarGrayScale:3-stop 会被 colorAt 阶梯压成 3 级)。
|
||||
const core::ColorScale scale = radarGrayScale(g->vmin, g->vmax);
|
||||
if (auto it2 = volumes_.find(dsId); it2 != volumes_.end()) {
|
||||
it2->second.cachedGrid = *g; // 缓存 → 下次命中直渲
|
||||
it2->second.cachedScale = scale;
|
||||
}
|
||||
onOk(*g, scale);
|
||||
};
|
||||
auto compute = [lineDir, linePrefix, coarse]() {
|
||||
auto compute = [lineDir, linePrefix, coarse, gainMode]() {
|
||||
std::shared_ptr<VolumeGrid> g;
|
||||
std::string err;
|
||||
try {
|
||||
g = std::make_shared<VolumeGrid>(
|
||||
geopro::data::createRadarVolumeGrid(lineDir, linePrefix, coarse));
|
||||
g = std::make_shared<VolumeGrid>(geopro::data::createRadarVolumeGrid(
|
||||
lineDir, linePrefix, coarse, /*targetDy=*/0.025, gainMode));
|
||||
} catch (const std::exception& e) {
|
||||
err = e.what();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
|
||||
#include "dto/Vtk3dRequests.hpp"
|
||||
#include "geo/GeoLocalFrame.hpp"
|
||||
#include "GprVolumeRepository.hpp" // RadarGainMode(雷达显示增益模式)
|
||||
#include "repo/I3dSceneRepository.hpp"
|
||||
#include "repo/VolumeBuildParams.hpp"
|
||||
|
||||
|
|
@ -54,6 +55,9 @@ public:
|
|||
std::string registerRadarDataset(const std::string& lineDir, const std::string& linePrefix,
|
||||
const std::string& name, const std::string& structParentId,
|
||||
int coarse = 4);
|
||||
// 切换雷达体显示增益模式(右键菜单):设新模式 + 清缓存体(强制下次 loadVolume 用新模式重建)。
|
||||
// 返回 true=是雷达体且已切换;false=非雷达体(忽略)。调用方随后清控制器缓存并重渲该 dsId。
|
||||
bool setRadarGainMode(const std::string& dsId, RadarGainMode mode);
|
||||
// 取回某三维体组装的请求体(测试/联调);非本 repo 创建或无 request 时返回 nullptr。
|
||||
const VoxelGenerateRequest* lastVoxelRequest(const std::string& dsId) const;
|
||||
// 清空内存态三维体/切片/异常(切换项目时调;否则上个项目的体/切片/异常残留在新项目列表)。
|
||||
|
|
@ -155,6 +159,7 @@ private:
|
|||
std::string ddCode = "dd_voxel"; // 数据字典码(雷达体="dd_radar_3d",其余默认 dd_voxel)
|
||||
std::string structParentId; // 结构归属(生成位置);雷达体无 request 时由此提供
|
||||
int coarse = 4; // 沿测线下采样因子(控内存)
|
||||
RadarGainMode gainMode = RadarGainMode::Agc; // 显示增益模式(右键切换;默认 AGC 显深部)
|
||||
};
|
||||
std::map<std::string, StoredVolume> volumes_; // dsId → 体
|
||||
int volumeCounter_ = 0;
|
||||
|
|
|
|||
|
|
@ -73,9 +73,18 @@ vtkSmartPointer<vtkVolume> assembleVolume(vtkImageData* img,
|
|||
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])); // 包围盒对角(最长穿越路径)
|
||||
const double ex = std::abs(bnd[1] - bnd[0]), ey = std::abs(bnd[3] - bnd[2]),
|
||||
ez = std::abs(bnd[5] - bnd[4]);
|
||||
const double diag = std::sqrt(ex * ex + ey * ey + ez * ez);
|
||||
// 不透明度单位距离的尺度基准:
|
||||
// - 【各向异性】体(细长,如雷达 375×1.4×5m):对角线被长轴主宰(375m)→ 单位距离过大 →
|
||||
// 不透明度只在 100% 才实心、稍降即很透(用户实测)。改用【特征尺度=三轴几何平均 cbrt】,
|
||||
// 对各向异性稳健。
|
||||
// - 【近立方】体(反演):维持原对角线,观感不变(门控:长短轴比 ≤ kAnisoRatio 走对角线)。
|
||||
constexpr double kAnisoRatio = 4.0;
|
||||
const double maxE = std::max({ex, ey, ez}), minE = std::min({ex, ey, ez});
|
||||
const bool anisotropic = (minE > 0.0) && (maxE / minE > kAnisoRatio);
|
||||
const double charLen = anisotropic ? std::cbrt(ex * ey * ez) : diag;
|
||||
|
||||
// 大体(如雷达:24M 体素、深度采样距 mm 级 → 单条光线上千采样步)开启【交互期】采样距自适应:
|
||||
// 旋转时 VTK 自动加大采样步(变粗)保流畅,停手即恢复设定的细采样距(0.3×minSp)出全质量帧。
|
||||
|
|
@ -112,11 +121,10 @@ vtkSmartPointer<vtkVolume> assembleVolume(vtkImageData* img,
|
|||
prop->SetScalarOpacity(opacity);
|
||||
prop->SetInterpolationTypeToLinear();
|
||||
prop->ShadeOff();
|
||||
// 不透明度单位距离 = 包围盒对角 × kOpacityUnitFraction:控制沿深度的累积速度,使色阶「不透明度」滑块
|
||||
// 有层次。取对角/10:100%(每单位=1.0)→沿体累积到≈实心、10% 很淡。太大(=整条对角)→100% 也偏透;
|
||||
// 太小(=体素)→ 低不透明度也累积到全不透明。
|
||||
// 不透明度单位距离 = 尺度基准(charLen:各向异性=特征尺度 / 近立方=对角线) × kOpacityUnitFraction。
|
||||
// 控制累积速度使「不透明度」滑块有层次;细长体走特征尺度后不再"只有 100% 实心、99% 即很透"。
|
||||
constexpr double kOpacityUnitFraction = 0.1;
|
||||
if (diag > 0) prop->SetScalarOpacityUnitDistance(kOpacityUnitFraction * diag);
|
||||
if (charLen > 0) prop->SetScalarOpacityUnitDistance(kOpacityUnitFraction * charLen);
|
||||
|
||||
auto volume = vtkSmartPointer<vtkVolume>::New();
|
||||
volume->SetMapper(mapper);
|
||||
|
|
|
|||
|
|
@ -58,11 +58,22 @@ void InteractionManager::installStyle() {
|
|||
style_->onPick = [this](const Vec3& w) { onPicked(w); };
|
||||
style_->onDoubleClick = [this](const Vec3& w) { onDoubleClicked(w); };
|
||||
style_->onWheelStep = [this](int dir) { return onWheel(dir); };
|
||||
// Esc 取消选中:清选中+高亮 + 同步列表清选 + 重渲(拉近后点不到空白处取消时的可靠出口)。
|
||||
style_->onDeselect = [this]() {
|
||||
if (selected_ < 0) return;
|
||||
deselectSlice();
|
||||
if (onSliceSelectionChanged) onSliceSelectionChanged(std::string{});
|
||||
safeRender();
|
||||
};
|
||||
// 精确"命中切片"判定:光标射线 vs 切片真实矩形求交(点帘面/非切片物/边界外都不算)。
|
||||
style_->hitTestSlice = [this]() { return pointOnSlice(); };
|
||||
// D39: 提供旋转中心 = 选中切片中心(有选中→true)。style 在按下拖动时据此绕选中切片旋转。
|
||||
style_->getRotateCenter = [this](Vec3& c) {
|
||||
if (selected_ < 0 || selected_ >= static_cast<int>(slices_.size())) return false;
|
||||
c = slices_[static_cast<std::size_t>(selected_)]->center();
|
||||
return true;
|
||||
if (selected_ >= 0 && selected_ < static_cast<int>(slices_.size())) {
|
||||
c = slices_[static_cast<std::size_t>(selected_)]->center(); // 选中切片→绕其中心
|
||||
return true;
|
||||
}
|
||||
return rayVolumePivot(c); // 无选中→绕光标射线穿过的体中段点(不甩飞);无体命中→false(默认焦点)
|
||||
};
|
||||
interactor_->SetInteractorStyle(style_);
|
||||
|
||||
|
|
@ -83,6 +94,8 @@ void InteractionManager::uninstallStyle() {
|
|||
style_->onDoubleClick = nullptr;
|
||||
style_->onWheelStep = nullptr;
|
||||
style_->getRotateCenter = nullptr;
|
||||
style_->onDeselect = nullptr;
|
||||
style_->hitTestSlice = nullptr;
|
||||
}
|
||||
// 摘除右键观察者(this 即将析构)。
|
||||
if (interactor_ && rightBtnTag_ != 0) {
|
||||
|
|
@ -470,6 +483,114 @@ int InteractionManager::nearestSlice(const Vec3& worldPoint) const {
|
|||
return idx;
|
||||
}
|
||||
|
||||
bool InteractionManager::cursorRay(double nearP[3], double dir[3]) const {
|
||||
if (!interactor_ || !renderer_) return false;
|
||||
const int* pos = interactor_->GetEventPosition();
|
||||
if (!pos) return false;
|
||||
// 屏幕点在近/远裁剪面的世界坐标 → 连成视线(相机透视/正交均适用)。
|
||||
auto toWorld = [this](int x, int y, double z, double out[3]) {
|
||||
renderer_->SetDisplayPoint(x, y, z);
|
||||
renderer_->DisplayToWorld();
|
||||
double w[4];
|
||||
renderer_->GetWorldPoint(w);
|
||||
const double iw = (w[3] != 0.0) ? 1.0 / w[3] : 1.0;
|
||||
out[0] = w[0] * iw;
|
||||
out[1] = w[1] * iw;
|
||||
out[2] = w[2] * iw;
|
||||
};
|
||||
double farP[3];
|
||||
toWorld(pos[0], pos[1], 0.0, nearP);
|
||||
toWorld(pos[0], pos[1], 1.0, farP);
|
||||
dir[0] = farP[0] - nearP[0];
|
||||
dir[1] = farP[1] - nearP[1];
|
||||
dir[2] = farP[2] - nearP[2];
|
||||
return true;
|
||||
}
|
||||
|
||||
bool InteractionManager::rayVolumePivot(Vec3& out) const {
|
||||
double nearP[3], d[3];
|
||||
if (!cursorRay(nearP, d)) return false;
|
||||
bool found = false;
|
||||
double bestEnter = 0.0;
|
||||
for (const auto& kv : volumes_) {
|
||||
if (!kv.second.image) continue;
|
||||
const std::array<double, 6> b = imageBounds(kv.second.image);
|
||||
double tEnter = -1e300, tExit = 1e300; // slab 法求 ray∩包围盒 [tEnter,tExit]
|
||||
bool ok = true;
|
||||
for (int i = 0; i < 3; ++i) {
|
||||
const double lo = b[2 * i], hi = b[2 * i + 1];
|
||||
if (std::abs(d[i]) < 1e-12) {
|
||||
if (nearP[i] < lo || nearP[i] > hi) {
|
||||
ok = false;
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
double t1 = (lo - nearP[i]) / d[i], t2 = (hi - nearP[i]) / d[i];
|
||||
if (t1 > t2) {
|
||||
const double tmp = t1;
|
||||
t1 = t2;
|
||||
t2 = tmp;
|
||||
}
|
||||
if (t1 > tEnter) tEnter = t1;
|
||||
if (t2 < tExit) tExit = t2;
|
||||
}
|
||||
}
|
||||
if (!ok || tExit < tEnter || tExit < 0.0) continue;
|
||||
const double tin = (tEnter > 0.0 ? tEnter : 0.0);
|
||||
if (!found || tin < bestEnter) { // 多体取最近命中
|
||||
bestEnter = tin;
|
||||
const double tmid = 0.5 * (tin + tExit); // 体内中段点 → 稳定支点
|
||||
out = {nearP[0] + d[0] * tmid, nearP[1] + d[1] * tmid, nearP[2] + d[2] * tmid};
|
||||
found = true;
|
||||
}
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
bool InteractionManager::pointOnSlice() const {
|
||||
double nearP[3], d[3];
|
||||
if (!cursorRay(nearP, d)) return false;
|
||||
for (const auto& sp : slices_) {
|
||||
double o[3], p1[3], p2[3];
|
||||
sp->planePoints(o, p1, p2); // 切片真实矩形:o + u·e1 + v·e2 (u,v∈[0,1])
|
||||
const double e1[3] = {p1[0] - o[0], p1[1] - o[1], p1[2] - o[2]};
|
||||
const double e2[3] = {p2[0] - o[0], p2[1] - o[1], p2[2] - o[2]};
|
||||
const double n[3] = {e1[1] * e2[2] - e1[2] * e2[1], e1[2] * e2[0] - e1[0] * e2[2],
|
||||
e1[0] * e2[1] - e1[1] * e2[0]};
|
||||
const double denom = d[0] * n[0] + d[1] * n[1] + d[2] * n[2];
|
||||
if (std::abs(denom) < 1e-12) continue; // 视线平行于切面
|
||||
const double t =
|
||||
((o[0] - nearP[0]) * n[0] + (o[1] - nearP[1]) * n[1] + (o[2] - nearP[2]) * n[2]) / denom;
|
||||
if (t < 0.0) continue; // 交点在相机后方
|
||||
const double h[3] = {nearP[0] + d[0] * t, nearP[1] + d[1] * t, nearP[2] + d[2] * t};
|
||||
const double hd[3] = {h[0] - o[0], h[1] - o[1], h[2] - o[2]};
|
||||
const double e1sq = e1[0] * e1[0] + e1[1] * e1[1] + e1[2] * e1[2];
|
||||
const double e2sq = e2[0] * e2[0] + e2[1] * e2[1] + e2[2] * e2[2];
|
||||
if (e1sq <= 0.0 || e2sq <= 0.0) continue;
|
||||
const double u = (hd[0] * e1[0] + hd[1] * e1[1] + hd[2] * e1[2]) / e1sq;
|
||||
const double v = (hd[0] * e2[0] + hd[1] * e2[1] + hd[2] * e2[2]) / e2sq;
|
||||
if (u < 0.0 || u > 1.0 || v < 0.0 || v > 1.0) continue; // 不在切片矩形内
|
||||
// 矩形命中后还需该点处切片有【可见数据】(不透明)——切片矩形=体网格截面,常比可见数据大
|
||||
// (反演有大片空值网格、雷达边缘空采样)。落在矩形但透明(空值/外区)处不算"在切片上",
|
||||
// 否则点 ds 边缘空白区(如截图左侧标尺处)仍误判命中 → 治用户实测的外扩。
|
||||
const vtkSmartPointer<vtkImageData> rgba = sp->coloredResliceImage();
|
||||
if (!rgba) return true; // 无着色图(理论不至) → 退化为矩形命中
|
||||
int dims[3];
|
||||
rgba->GetDimensions(dims);
|
||||
if (dims[0] < 1 || dims[1] < 1 || rgba->GetNumberOfScalarComponents() < 4)
|
||||
return true; // 无 alpha 通道 → 退化为矩形命中
|
||||
// 着色输出像素 (i,j) 沿 e1/e2 方向 → (u,v)·(dim-1)(vtkImagePlaneWidget reslice 轴约定)。
|
||||
int px = static_cast<int>(u * (dims[0] - 1) + 0.5);
|
||||
int py = static_cast<int>(v * (dims[1] - 1) + 0.5);
|
||||
px = px < 0 ? 0 : (px > dims[0] - 1 ? dims[0] - 1 : px);
|
||||
py = py < 0 ? 0 : (py > dims[1] - 1 ? dims[1] - 1 : py);
|
||||
const auto* pix = static_cast<const unsigned char*>(rgba->GetScalarPointer(px, py, 0));
|
||||
if (pix && pix[3] > 10) return true; // alpha>阈值 = 该点切片可见 → 在切片上
|
||||
// 透明(空值/外区) → 不算在此切片上,继续看其它切片
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void InteractionManager::onPicked(const Vec3& worldPoint) {
|
||||
// 单击 = 选中命中切片;点在切片外(如点到体/帘面)→ 取消选中(idx=-1)。**不动相机**。
|
||||
// 解决"选了切片无法取消":点击切片之外即清选中,滚轮恢复缩放(见 onWheel)。
|
||||
|
|
@ -497,7 +618,20 @@ void InteractionManager::faceSlice(int idx) {
|
|||
if (!cam) return;
|
||||
const Vec3 focal = slices_[static_cast<std::size_t>(idx)]->center();
|
||||
const Vec3 normal = slices_[static_cast<std::size_t>(idx)]->normal();
|
||||
const double dist = cam->GetDistance(); // 保持当前观察距离
|
||||
// 缩放到切片:按切片【面内尺寸】(法向两侧两轴的跨度)+ 相机视角算"刚好框住"的距离,
|
||||
// 而非沿用当前(拉远看整条线时可能几百米)距离——否则正视后切片又小又远(用户实测)。
|
||||
const VolumeImg* vol = volumeOf(slices_[static_cast<std::size_t>(idx)]->volumeDsId());
|
||||
const std::array<double, 6> b = imageBounds(vol ? vol->image : nullptr);
|
||||
const double ext[3] = {b[1] - b[0], b[3] - b[2], b[5] - b[4]};
|
||||
const double an[3] = {std::abs(normal[0]), std::abs(normal[1]), std::abs(normal[2])};
|
||||
const int ax = (an[0] >= an[1] && an[0] >= an[2]) ? 0 : (an[1] >= an[2] ? 1 : 2); // 法向主轴
|
||||
double inMax = 0.0;
|
||||
for (int i = 0; i < 3; ++i)
|
||||
if (i != ax) inMax = (ext[i] > inMax ? ext[i] : inMax); // 面内最大尺寸
|
||||
double dist = cam->GetDistance(); // 兜底(无体边界)
|
||||
const double vaRad = cam->GetViewAngle() * 3.14159265358979323846 / 180.0;
|
||||
if (inMax > 0.0 && vaRad > 0.0)
|
||||
dist = (0.5 * inMax) / std::tan(0.5 * vaRad) * 1.1; // 框住面内最大尺寸 + 10% 余量
|
||||
const FaceOnCamera face = faceOnCamera(focal, normal, dist);
|
||||
cam->SetFocalPoint(focal[0], focal[1], focal[2]);
|
||||
cam->SetPosition(face.position[0], face.position[1], face.position[2]);
|
||||
|
|
@ -513,7 +647,17 @@ bool InteractionManager::onWheel(int dir) {
|
|||
// 配合 onPicked 的"点击切片外取消选中":取消后滚轮即恢复缩放,解决"选了切片无法缩放"。
|
||||
// (不采用"悬停即推进":推进时鼠标难持续压在移动的切片上,且过敏感。)
|
||||
if (selected_ < 0 || selected_ >= static_cast<int>(slices_.size())) return false;
|
||||
const double step = wheelStep(imageBounds(selectedVolumeImage()), dir); // 选中切片所属体
|
||||
// 步长按切片【法向上的体素间距 × N】算:一格挪 N 个采样,与体总长无关——细长雷达体也不会
|
||||
// 因长轴 375m 而步太大/跳过。Shift=粗调(×10);超长轴粗定位另靠沿线滑块(后续)。
|
||||
const Vec3 n = slices_[static_cast<std::size_t>(selected_)]->normal();
|
||||
std::array<double, 3> sp{1.0, 1.0, 1.0};
|
||||
if (vtkImageData* img = selectedVolumeImage()) {
|
||||
double s[3];
|
||||
img->GetSpacing(s);
|
||||
sp = {s[0], s[1], s[2]};
|
||||
}
|
||||
const int voxels = (interactor_ && interactor_->GetShiftKey()) ? 20 : 2; // Shift=粗调
|
||||
const double step = wheelStep(sp, n, voxels, dir);
|
||||
slices_[static_cast<std::size_t>(selected_)]->advance(step);
|
||||
safeRender();
|
||||
return true; // 消费滚轮(推进选中切片,不缩放)
|
||||
|
|
|
|||
|
|
@ -142,6 +142,14 @@ private:
|
|||
|
||||
// 找离世界点最近的切片索引;无切片返回 -1。
|
||||
int nearestSlice(const Vec3& worldPoint) const;
|
||||
// 精确判定:当前光标【射线】是否穿过某张切片真实矩形(origin/point1/point2)内。
|
||||
// 用射线-矩形求交(非带容差的 picker 点)→ 切片边界外不再误判命中(治外扩)。点帘面也判 false。
|
||||
bool pointOnSlice() const;
|
||||
// 当前光标射线:近/远裁剪面世界点 → nearP + 方向 dir。无 interactor/renderer 返回 false。
|
||||
bool cursorRay(double nearP[3], double dir[3]) const;
|
||||
// 旋转支点(B 方案#1):无选中切片时,取光标射线穿过的体【中段点】(进/出包围盒中点),多体取最近命中。
|
||||
// 绕它旋转→当前所视区域居中、不甩飞(治长体旋转丢目标)。无体命中返回 false(回退默认绕焦点)。
|
||||
bool rayVolumePivot(Vec3& out) const;
|
||||
// 在当前鼠标屏幕位置拾取 → 命中的切片索引;未命中切片返回 -1。
|
||||
int pickSliceAtCursor() const;
|
||||
// 按 SliceTool 指针设为选中(widget 交互回调用:触碰即选中)。
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
#include <chrono>
|
||||
#include <cmath>
|
||||
#include <cstring>
|
||||
|
||||
#include <vtkCallbackCommand.h>
|
||||
#include <vtkCamera.h>
|
||||
|
|
@ -66,14 +67,17 @@ void PickInteractorStyle::OnLeftButtonDown() {
|
|||
return;
|
||||
}
|
||||
Vec3 world;
|
||||
const bool hit = pickWorld(world);
|
||||
const bool hit = pickWorld(world); // 仍用于取选中所需世界点(onPick)
|
||||
// 命中切片【精确判定】:光标射线穿过某切片真实矩形内才算(不靠带容差的 picker 点)。
|
||||
// 边界外/帘面/其它物 → false → 抬键单击即取消。
|
||||
downHitSlice_ = hitTestSlice && hitTestSlice();
|
||||
|
||||
// 手动双击判定(GetRepeatCount 在 QVTK+Windows 不可靠,评审 M5):
|
||||
// 两次左键按下间隔 < 阈值且屏幕位置相近 → 双击。
|
||||
const double now = nowMs();
|
||||
const int* pos = iren ? iren->GetEventPosition() : nullptr;
|
||||
bool isDouble = false;
|
||||
if (hit && pos && lastDownTime_ >= 0.0) {
|
||||
if (downHitSlice_ && pos && lastDownTime_ >= 0.0) { // 仅命中切片才判双击(正视)
|
||||
const double dtMs = now - lastDownTime_;
|
||||
const int dx = pos[0] - lastDownPos_[0];
|
||||
const int dy = pos[1] - lastDownPos_[1];
|
||||
|
|
@ -91,21 +95,24 @@ void PickInteractorStyle::OnLeftButtonDown() {
|
|||
lastDownTime_ = -1.0; // 重置,避免三击连判
|
||||
return; // 不进入拖动旋转
|
||||
}
|
||||
if (hit) {
|
||||
// 单击命中 → 选中所在切片(onPick 内仅选中, 不动相机)。
|
||||
if (downHitSlice_ && hit) {
|
||||
// 单击命中【切片】→ 选中(onPick 内仅选中, 不动相机)。点帘面/非切片物/边界外不选中→抬键即取消。
|
||||
if (onPick) onPick(world);
|
||||
}
|
||||
// 不在按下时动相机(动相机=跳);绕选中物旋转在 Rotate() 内做(增量绕支点,不跳)。
|
||||
// 捕获本次拖动的旋转支点【一次】(在 onPick 选中之后取,故能用新选中切片):拖动中光标会移动,
|
||||
// 不能每帧重取(否则支点漂)。选中切片→其中心;否则→光标射线穿过的体中段点;无→默认绕焦点。
|
||||
hasRotatePivot_ = (getRotateCenter && getRotateCenter(rotatePivot_));
|
||||
// 不在按下时动相机(动相机=跳);绕支点旋转在 Rotate() 内做(增量绕支点,不跳)。
|
||||
Superclass::OnLeftButtonDown();
|
||||
}
|
||||
|
||||
void PickInteractorStyle::Rotate() {
|
||||
if (lock2D_) return; // 二维分析禁旋转(仅平移+缩放)
|
||||
Vec3 c;
|
||||
if (!this->CurrentRenderer || !getRotateCenter || !getRotateCenter(c)) {
|
||||
Superclass::Rotate(); // 无选中物 → 默认绕焦点旋转
|
||||
if (!this->CurrentRenderer || !hasRotatePivot_) {
|
||||
Superclass::Rotate(); // 无支点 → 默认绕焦点旋转
|
||||
return;
|
||||
}
|
||||
const Vec3 c = rotatePivot_; // 按下时已捕获、拖动中固定
|
||||
auto* rwi = this->Interactor;
|
||||
auto* cam = this->CurrentRenderer->GetActiveCamera();
|
||||
if (!rwi || !cam) return;
|
||||
|
|
@ -114,8 +121,10 @@ void PickInteractorStyle::Rotate() {
|
|||
const int* size = this->CurrentRenderer->GetRenderWindow()->GetSize();
|
||||
if (size[0] <= 0 || size[1] <= 0) return;
|
||||
// 与 TrackballCamera 同口径的角度映射。
|
||||
// 俯仰:本类绕 right=cross(DOP,up) 旋转,而 VTK 默认 Elevation 绕 cross(-DOP,up)=-right →
|
||||
// 轴反向。故 elevation 取 +20(非 -20)抵消,使选中切片后上下方向与未选中(默认)时一致。
|
||||
const double azimuth = dx * (-20.0 / size[0]) * this->MotionFactor;
|
||||
const double elevation = dy * (-20.0 / size[1]) * this->MotionFactor;
|
||||
const double elevation = dy * (20.0 / size[1]) * this->MotionFactor;
|
||||
|
||||
double up[3], dop[3], right[3];
|
||||
cam->GetViewUp(up);
|
||||
|
|
@ -185,6 +194,17 @@ void PickInteractorStyle::OnLeftButtonUp() {
|
|||
if (onDrag2DEnd) onDrag2DEnd();
|
||||
return;
|
||||
}
|
||||
// 单击(抬键位移<阈值=非拖动)且按下未命中切片 → 取消选中(点空/点体;体 PickableOff 故点体也 hit=false)。
|
||||
// 拖空白旋转:抬键位移大 → 不取消,保留"绕选中切片旋转"。Esc 仍是完全拉近时的兜底。
|
||||
if (!lock2D_ && !downHitSlice_ && onDeselect) {
|
||||
auto* iren = this->GetInteractor();
|
||||
const int* up = iren ? iren->GetEventPosition() : nullptr;
|
||||
if (up) {
|
||||
const int dx = up[0] - lastDownPos_[0], dy = up[1] - lastDownPos_[1];
|
||||
if (dx * dx + dy * dy <= kClickSlopPx2) onDeselect(); // 是单击 → 取消
|
||||
}
|
||||
}
|
||||
hasRotatePivot_ = false; // 拖动结束 → 清支点(下次按下重新捕获)
|
||||
Superclass::OnLeftButtonUp(); // 平移/旋转/缩放等由基类按 State 收尾
|
||||
}
|
||||
|
||||
|
|
@ -205,4 +225,15 @@ void PickInteractorStyle::OnMouseWheelBackward() {
|
|||
Superclass::OnMouseWheelBackward();
|
||||
}
|
||||
|
||||
void PickInteractorStyle::OnKeyPress() {
|
||||
// Esc → 取消选中切片(拉近后满屏切片、点不到空白处取消时的可靠出口)。其它键走默认。
|
||||
auto* iren = this->GetInteractor();
|
||||
const char* sym = iren ? iren->GetKeySym() : nullptr;
|
||||
if (sym && std::strcmp(sym, "Escape") == 0) {
|
||||
if (onDeselect) onDeselect();
|
||||
return;
|
||||
}
|
||||
Superclass::OnKeyPress();
|
||||
}
|
||||
|
||||
} // namespace geopro::render::interact
|
||||
|
|
|
|||
|
|
@ -30,6 +30,12 @@ public:
|
|||
// 取当前旋转中心(D39):有选中三维体/切片→填其中心、返回 true;否则 false(绕默认焦点)。
|
||||
// 在"按下开始拖动"时调用一次,把焦点设到该中心(位置同步补偿,画面不变)→ 之后绕它旋转、不跳。
|
||||
std::function<bool(Vec3& center)> getRotateCenter;
|
||||
// 取消选中切片(Esc 键触发)。拉近后满屏切片、点不到空白处取消时的可靠出口。
|
||||
std::function<void()> onDeselect;
|
||||
// 精确判定:当前光标【射线】是否穿过某张切片的真实矩形(origin/point1/point2)内。
|
||||
// 不靠带容差/夹取的 picker 命中点 → 切片边界外不再误判命中(用户实测的外扩)。
|
||||
// 点帘面/其它非切片物/边界外 → 返回 false → 单击即取消选中。
|
||||
std::function<bool()> hitTestSlice;
|
||||
|
||||
// 二维分析锁:开 → 左键拖动改为平移、禁旋转(仅平移+缩放);关 → 恢复三维拾取/旋转交互。
|
||||
void setLock2D(bool on) { lock2D_ = on; }
|
||||
|
|
@ -51,6 +57,7 @@ public:
|
|||
void OnLeftButtonDown() override;
|
||||
void OnMouseWheelForward() override;
|
||||
void OnMouseWheelBackward() override;
|
||||
void OnKeyPress() override; // Esc → onDeselect(取消选中切片)
|
||||
// 绕选中物中心旋转(D39):有 getRotateCenter 时, 绕该中心增量旋转整个相机(位置+焦点+up),
|
||||
// 中心在世界/屏幕都不动→不跳; 否则回退默认(绕焦点)。
|
||||
void Rotate() override;
|
||||
|
|
@ -68,6 +75,15 @@ private:
|
|||
// 记上次左键按下时刻+屏幕位置,两次按下间隔 < kDoubleClickMs 且位置相近视为双击。
|
||||
double lastDownTime_ = -1.0; // 单调时钟(毫秒),-1=无
|
||||
int lastDownPos_[2] = {0, 0};
|
||||
// 左键按下时是否命中【切片】(精确:经 hitTestSlice 判点在切片矩形内,非"任意可拾取物")。
|
||||
// 抬键若为单击(无拖动)且未命中切片 → 取消选中(点空/点体/帘面/其它非切片物)。
|
||||
// 体 PickableOff、帘面虽可拾取但 hitTestSlice 判其非切片 → 都走取消。拖动则不取消(保留旋转)。
|
||||
bool downHitSlice_ = false;
|
||||
|
||||
// 旋转支点:按下(拖动起点)时经 getRotateCenter 捕获一次,拖动中固定不漂(光标会动→不可每帧重取)。
|
||||
// 选中切片=其中心;否则=光标射线穿过的体中段点。无则 hasRotatePivot_=false→默认绕焦点。
|
||||
Vec3 rotatePivot_{};
|
||||
bool hasRotatePivot_ = false;
|
||||
|
||||
// 二维分析模式:左键=平移、禁旋转(仅平移+缩放)。由 InteractionManager 在切 tab 时设。
|
||||
bool lock2D_ = false;
|
||||
|
|
|
|||
|
|
@ -56,10 +56,12 @@ Vec3 clampToBounds(const Vec3& origin, const std::array<double, 6>& b) {
|
|||
clamp1(origin[2], b[4], b[5])};
|
||||
}
|
||||
|
||||
double wheelStep(const std::array<double, 6>& b, int dir) {
|
||||
const double dx = b[1] - b[0], dy = b[3] - b[2], dz = b[5] - b[4];
|
||||
const double diag = std::sqrt(dx * dx + dy * dy + dz * dz);
|
||||
const double mag = diag * 0.02; // 一次滚轮 ≈ 1/50 对角线
|
||||
double wheelStep(const std::array<double, 3>& spacing, const Vec3& normal, int voxels, int dir) {
|
||||
// 沿法向的体素间距 = 各轴间距在法向上的投影绝对值和(轴向切片即取对应轴间距)。
|
||||
// 一格 = voxels 个采样,与体总长无关 → 超长轴(雷达沿线)也只挪几个采样、不跳过、不过冲。
|
||||
const double sn = std::abs(spacing[0] * normal[0]) + std::abs(spacing[1] * normal[1]) +
|
||||
std::abs(spacing[2] * normal[2]);
|
||||
const double mag = (sn > 0.0 ? sn : 1.0) * (voxels > 0 ? voxels : 1);
|
||||
return (dir >= 0 ? mag : -mag);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -43,9 +43,10 @@ struct FaceOnCamera {
|
|||
};
|
||||
FaceOnCamera faceOnCamera(const Vec3& focal, const Vec3& normal, double dist);
|
||||
|
||||
// 滚轮推进步长:取包围盒对角线长度的固定比例 × 方向(±1)。
|
||||
// 使一次滚轮在体内移动适中(约 1/50 对角线);dir>0 沿法向、dir<0 反向。
|
||||
double wheelStep(const std::array<double, 6>& bounds, int dir);
|
||||
// 滚轮推进步长:按【沿切片法向的体素间距】× voxels × 方向(±1),即一格移动 voxels 个采样。
|
||||
// 与体总长无关(不会因长轴 375m 而步太大、也不因比例而跳过内容);spacing=体的三轴间距(含纵向夸张)。
|
||||
// voxels 小=细调;调用方可在 Shift 时传更大值做粗调。超长轴的粗定位另靠"沿线滑块",非滚轮。
|
||||
double wheelStep(const std::array<double, 3>& spacing, const Vec3& normal, int voxels, int dir);
|
||||
|
||||
// 在切片中心列表中找离世界点最近的索引(按到平面的距离最小)。
|
||||
// centers/normals 等长;空列表返回 -1。worldPoint 在哪张切片上→该索引。
|
||||
|
|
|
|||
|
|
@ -57,12 +57,42 @@ TEST(GprVolumeRepositoryAdapter, DequantDimsSpacingAndBlank) {
|
|||
EXPECT_DOUBLE_EQ(g.spacing[1], 1.37);
|
||||
EXPECT_DOUBLE_EQ(g.spacing[2], 0.05);
|
||||
|
||||
// 物理值域 = BuiltI16 的 vminPhys/vmaxPhys。
|
||||
EXPECT_DOUBLE_EQ(g.vmin, 5.0);
|
||||
EXPECT_DOUBLE_EQ(g.vmax, 20.0);
|
||||
// 显示值域 = 双极对称窗口(以中位数为中心),非全 vminPhys/vmaxPhys。3 个有效体素 {9,12,20}:
|
||||
// 中位数=12 → 窗口对称、【中点=中位数 12】(灰点落基线)。vmin<vmax。
|
||||
EXPECT_LT(g.vmin, g.vmax);
|
||||
EXPECT_NEAR(0.5 * (g.vmin + g.vmax), 12.0, 1e-9); // 对称中点=中位数(基线→中灰)
|
||||
EXPECT_TRUE(g.valid());
|
||||
}
|
||||
|
||||
// 双极对称显示窗口:强离群(模拟原始 GPR 首波/路面/int16 饱和钳值)落在 1% 尾外应被裁剪,
|
||||
// 窗口对称且中点落基线(中位数),而非被 ±极值撑满(否则结构压成中灰"灰板"或过饱和)。
|
||||
TEST(GprVolumeRepositoryAdapter, RobustDisplayRangeClipsOutliers) {
|
||||
geopro::core::BuiltI16 built;
|
||||
built.vol = geopro::core::ScalarVolumeI16(1004, 1, 1);
|
||||
built.quant.scale = 1.0; // phys = q
|
||||
built.quant.offset = 0.0;
|
||||
built.origin = {0.0, 0.0, 0.0};
|
||||
built.spacing = {0.1, 0.1, 0.05};
|
||||
built.vminPhys = -30000.0; // 全值域(含离群)——不应被采用为显示值域
|
||||
built.vmaxPhys = 30000.0;
|
||||
// 1000 个"结构"体素 q=-500..499(基线≈0) + 各 2 个 ±30000 强离群(共 1004,离群 0.4% < 1% 尾)。
|
||||
for (int i = 0; i < 1000; ++i)
|
||||
built.vol.at(i, 0, 0) = static_cast<std::int16_t>(i - 500);
|
||||
built.vol.at(1000, 0, 0) = 30000;
|
||||
built.vol.at(1001, 0, 0) = 30000;
|
||||
built.vol.at(1002, 0, 0) = -30000;
|
||||
built.vol.at(1003, 0, 0) = -30000;
|
||||
|
||||
const geopro::data::VolumeGrid g = geopro::data::builtI16ToVolumeGrid(built);
|
||||
|
||||
// 对称 99% 窗口裁掉两端 0.4% 离群 → 显示窗落在结构范围(±500)内,远离 ±30000。
|
||||
EXPECT_GT(g.vmin, -1000.0); // 负向离群被裁
|
||||
EXPECT_LT(g.vmax, 1000.0); // 正向离群被裁
|
||||
EXPECT_NEAR(0.5 * (g.vmin + g.vmax), 0.0, 5.0); // 对称:中点≈基线(中位数≈0)
|
||||
// 数据本身仍保留离群(只是显示窗收窄)——抽查离群体素反量化值未被改动。
|
||||
EXPECT_DOUBLE_EQ(g.vol.at(1000, 0, 0), 30000.0);
|
||||
}
|
||||
|
||||
// 写一个合成通道:.iprh 文本头 + .iprb 纯 int16 波形([trace*samples + s],s 最快)。
|
||||
// 与 test_gpr3dv_volume_bridge 同口径,确保 createGprVolumeGrid 走真 P1/P2 链。
|
||||
void writeSyntheticChannel(const fs::path& iprhPath, int samples, int traces,
|
||||
|
|
|
|||
|
|
@ -37,3 +37,56 @@ TEST(RadarVolumeAssembler, CoarseDownsamplesTracesAndScalesDx) {
|
|||
EXPECT_DOUBLE_EQ(b.spacing[0], 0.2);
|
||||
EXPECT_NEAR(b.quant.toPhys(b.vol.at(1, 0, 0)), 20.0, b.quant.scale); // 输出道1 = 源道2
|
||||
}
|
||||
|
||||
// ── 合成靶标:在【装配出的体】里验通道插值的几何忠实度 ──────────────────────
|
||||
// 现有 test_gpr_geometry 只验 planChannelInterpolation 的【行规划】;这两个测试验
|
||||
// assembleRadarVolume 把规划【应用到体】是否正确——即用户要判断的"通道插值在体里
|
||||
// 对不对、会不会造缝"。
|
||||
// 布局:3 通道偏移 {0, 0.10, 0.20},targetDy=0.05 → ny=round(0.2/0.05)+1=5:
|
||||
// 行 j0=ch0 / j1=blend(ch0,ch1,0.5) / j2=ch1 / j3=blend(ch1,ch2,0.5) / j4=ch2。
|
||||
namespace {
|
||||
RadarCubeDesc make3ChDesc() {
|
||||
RadarCubeDesc d;
|
||||
d.channels = 3; d.traces = 2; d.samples = 3;
|
||||
d.dxBase = 0.1; d.dz = 0.05;
|
||||
d.chXOffsets = {0.0, 0.10, 0.20}; // 触发通道插值
|
||||
return d;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
// 平层反射(同一深度 s=2 全通道等值 500)穿过通道插值后【全部行仍等于 500】——
|
||||
// 不出现"插值行衰减/锯齿/横向缝"(用户最担心的 10cm 缝就是这条若失败)。
|
||||
TEST(RadarVolumeAssembler, FlatReflectorStaysFlatAcrossInterpolatedRows) {
|
||||
const RadarCubeDesc d = make3ChDesc();
|
||||
// s==2:平层反射(全通道 500);s==0:逐通道阶梯(ch0=100/ch1=200/ch2=300)验混合;其余 0。
|
||||
auto sampler = [](int c, int /*t*/, int s) {
|
||||
if (s == 2) return 500.0;
|
||||
if (s == 0) return 100.0 * (c + 1);
|
||||
return 0.0;
|
||||
};
|
||||
const geopro::core::BuiltI16 b = assembleRadarVolume(d, sampler, /*coarse=*/1, /*targetDy=*/0.05);
|
||||
ASSERT_EQ(b.vol.ny(), 5); // 3 通道 → 5 行(含 2 条插值)
|
||||
ASSERT_EQ(b.vol.nx(), 2);
|
||||
ASSERT_EQ(b.vol.nz(), 3);
|
||||
EXPECT_DOUBLE_EQ(b.spacing[1], 0.05); // 插值后 dy=targetDy
|
||||
// 平层在【每一行、每一道】都应保持 500(插值不破坏横向连续)。
|
||||
for (int j = 0; j < b.vol.ny(); ++j)
|
||||
for (int to = 0; to < b.vol.nx(); ++to)
|
||||
EXPECT_NEAR(b.quant.toPhys(b.vol.at(to, j, 2)), 500.0, b.quant.scale)
|
||||
<< "行 j=" << j << " 道 to=" << to << " 处平层被插值破坏";
|
||||
}
|
||||
|
||||
// 插值行 = 相邻两通道的正确线性混合(j1 在 ch0=100/ch1=200 之间 wb=0.5 → 150);
|
||||
// 纯通道行 = 原通道值(j0=100 / j2=200 / j4=300)。验"插值没造假峰、没错位"。
|
||||
TEST(RadarVolumeAssembler, InterpolatedRowIsCorrectLinearBlend) {
|
||||
const RadarCubeDesc d = make3ChDesc();
|
||||
auto sampler = [](int c, int /*t*/, int s) { return s == 0 ? 100.0 * (c + 1) : 0.0; };
|
||||
const geopro::core::BuiltI16 b = assembleRadarVolume(d, sampler, /*coarse=*/1, /*targetDy=*/0.05);
|
||||
ASSERT_EQ(b.vol.ny(), 5);
|
||||
const double tol = b.quant.scale;
|
||||
EXPECT_NEAR(b.quant.toPhys(b.vol.at(0, 0, 0)), 100.0, tol); // j0 = ch0
|
||||
EXPECT_NEAR(b.quant.toPhys(b.vol.at(0, 1, 0)), 150.0, tol); // j1 = blend(100,200,0.5)
|
||||
EXPECT_NEAR(b.quant.toPhys(b.vol.at(0, 2, 0)), 200.0, tol); // j2 = ch1
|
||||
EXPECT_NEAR(b.quant.toPhys(b.vol.at(0, 3, 0)), 250.0, tol); // j3 = blend(200,300,0.5)
|
||||
EXPECT_NEAR(b.quant.toPhys(b.vol.at(0, 4, 0)), 300.0, tol); // j4 = ch2
|
||||
}
|
||||
|
|
|
|||
|
|
@ -95,17 +95,22 @@ TEST(SlicePlaneMath, FaceOnNormalizesNormal) {
|
|||
expectVec(cam.position, 0, 6, 0);
|
||||
}
|
||||
|
||||
// ── wheelStep:滚轮推进步长(按对角线比例 × 方向)──
|
||||
// ── wheelStep:步长 = 沿法向体素间距 × voxels × 方向(spacing=三轴间距,X法向取X间距)──
|
||||
TEST(SlicePlaneMath, WheelStepForwardPositive) {
|
||||
EXPECT_GT(wheelStep({0, 10, 0, 0, 0, 0}, +1), 0.0);
|
||||
EXPECT_GT(wheelStep({0.1, 0.1, 0.05}, {1, 0, 0}, 2, +1), 0.0);
|
||||
}
|
||||
TEST(SlicePlaneMath, WheelStepBackwardNegative) {
|
||||
EXPECT_LT(wheelStep({0, 10, 0, 0, 0, 0}, -1), 0.0);
|
||||
EXPECT_LT(wheelStep({0.1, 0.1, 0.05}, {1, 0, 0}, 2, -1), 0.0);
|
||||
}
|
||||
TEST(SlicePlaneMath, WheelStepScalesWithBounds) {
|
||||
const double small = wheelStep({0, 10, 0, 0, 0, 0}, 1);
|
||||
const double big = wheelStep({0, 100, 0, 0, 0, 0}, 1);
|
||||
EXPECT_GT(big, small); // 体越大步长越大
|
||||
TEST(SlicePlaneMath, WheelStepScalesWithVoxels) {
|
||||
const double fine = wheelStep({0.1, 0.1, 0.05}, {1, 0, 0}, 1, 1);
|
||||
const double coarse = wheelStep({0.1, 0.1, 0.05}, {1, 0, 0}, 10, 1);
|
||||
EXPECT_GT(coarse, fine); // voxels 越大步长越大(Shift 粗调)
|
||||
}
|
||||
// 只取法向那条轴的【间距】(非总长):长轴间距大也不影响 Z 法向步长;1 体素 = Z 间距 0.05。
|
||||
TEST(SlicePlaneMath, WheelStepUsesNormalAxisSpacing) {
|
||||
const double zStep = wheelStep({100.0, 0.1, 0.05}, {0, 0, 1}, 1, 1); // Z 法向 → 取 Z 间距 0.05
|
||||
EXPECT_NEAR(zStep, 0.05, 1e-9); // 与 X 间距 100 无关
|
||||
}
|
||||
|
||||
// ── nearestPlane:找点所在切片(按到平面距离最小)──
|
||||
|
|
|
|||
Loading…
Reference in New Issue