Feat:增加斜向角profile
parent
6cd6e39632
commit
79ad28fd58
|
|
@ -9,6 +9,7 @@
|
|||
#include <QComboBox>
|
||||
#include <QSlider>
|
||||
#include <QLabel>
|
||||
#include <QDoubleSpinBox>
|
||||
#include <QToolBar>
|
||||
#include <QHBoxLayout>
|
||||
#include <QVBoxLayout>
|
||||
|
|
@ -27,6 +28,7 @@
|
|||
|
||||
#include <algorithm>
|
||||
#include <initializer_list>
|
||||
#include <cmath>
|
||||
|
||||
ProfileComparePage::ProfileComparePage(QWidget* parent)
|
||||
: QWidget(parent)
|
||||
|
|
@ -83,6 +85,22 @@ void ProfileComparePage::buildUi()
|
|||
m_colorMapCombo->setMinimumWidth(110);
|
||||
m_colorMapCombo->blockSignals(false);
|
||||
tb->addWidget(m_colorMapCombo);
|
||||
|
||||
tb->addWidget(new QLabel(tr(" Profile角 "), this));
|
||||
m_angleSpin = new QDoubleSpinBox(this);
|
||||
m_angleSpin->setRange(-180.0, 180.0);
|
||||
m_angleSpin->setDecimals(1);
|
||||
m_angleSpin->setSingleStep(5.0);
|
||||
m_angleSpin->setValue(0.0);
|
||||
m_angleSpin->setSuffix(QStringLiteral("°"));
|
||||
m_angleSpin->setToolTip(tr(
|
||||
"0°=轴对齐。\n"
|
||||
"轴位/矢状:相对水平逆时针。\n"
|
||||
"冠状:相对竖直 SI 逆时针。\n"
|
||||
"绿箭头线为取样方向。"));
|
||||
m_angleSpin->setFixedWidth(100);
|
||||
tb->addWidget(m_angleSpin);
|
||||
|
||||
tb->addStretch(1);
|
||||
pageLay->addWidget(topBar);
|
||||
|
||||
|
|
@ -93,6 +111,8 @@ void ProfileComparePage::buildUi()
|
|||
connect(m_opacitySlider, &QSlider::valueChanged, this, &ProfileComparePage::onOpacityChanged);
|
||||
connect(m_colorMapCombo, QOverload<int>::of(&QComboBox::currentIndexChanged),
|
||||
this, &ProfileComparePage::onColorMapChanged);
|
||||
connect(m_angleSpin, QOverload<double>::of(&QDoubleSpinBox::valueChanged),
|
||||
this, &ProfileComparePage::onProfileAngleChanged);
|
||||
|
||||
auto* central = new QWidget(this);
|
||||
auto* root = new QHBoxLayout(central);
|
||||
|
|
@ -147,9 +167,10 @@ void ProfileComparePage::buildUi()
|
|||
right->addLayout(profRow, 2);
|
||||
|
||||
m_statusLabel = new QLabel(
|
||||
tr("可拖入 mhd/nii/dcm。Ctrl+在1/2/3上移动可重切另两面。"), this);
|
||||
tr("可拖入 mhd/nii/dcm。Ctrl+在1/2/3上移动可重切另两面。Profile角非0时沿绿箭头斜线取样。"), this);
|
||||
m_statusLabel->setContentsMargins(8, 4, 8, 4);
|
||||
pageLay->addWidget(m_statusLabel);
|
||||
updateProfileTitles();
|
||||
}
|
||||
|
||||
QString ProfileComparePage::defaultOpenDir() const
|
||||
|
|
@ -586,6 +607,39 @@ void ProfileComparePage::setCursor(const Vec3d& world, bool fromSlider)
|
|||
refreshProfiles();
|
||||
}
|
||||
|
||||
void ProfileComparePage::onProfileAngleChanged(double deg)
|
||||
{
|
||||
if (m_axial)
|
||||
m_axial->setProfileAngleDeg(deg);
|
||||
if (m_sagittal)
|
||||
m_sagittal->setProfileAngleDeg(deg);
|
||||
if (m_coronal)
|
||||
m_coronal->setProfileAngleDeg(deg);
|
||||
updateProfileTitles();
|
||||
refreshProfiles();
|
||||
}
|
||||
|
||||
void ProfileComparePage::updateProfileTitles()
|
||||
{
|
||||
const double deg = m_angleSpin ? m_angleSpin->value() : 0.0;
|
||||
if (std::abs(deg) < 1e-6) {
|
||||
if (m_profAxial)
|
||||
m_profAxial->setTitle(tr("4 Profile — 沿 LR (X) [可视范围]"));
|
||||
if (m_profSagittal)
|
||||
m_profSagittal->setTitle(tr("5 Profile — 沿 AP (Y) [可视范围]"));
|
||||
if (m_profCoronal)
|
||||
m_profCoronal->setTitle(tr("6 Profile — 沿 SI (Z) [可视范围]"));
|
||||
} else {
|
||||
const QString ang = QString::number(deg, 'f', 1);
|
||||
if (m_profAxial)
|
||||
m_profAxial->setTitle(tr("4 Profile — 轴位斜向 %1° [距交点 mm]").arg(ang));
|
||||
if (m_profSagittal)
|
||||
m_profSagittal->setTitle(tr("5 Profile — 矢状斜向 %1° [距交点 mm]").arg(ang));
|
||||
if (m_profCoronal)
|
||||
m_profCoronal->setTitle(tr("6 Profile — 冠状斜向 %1° [距交点 mm]").arg(ang));
|
||||
}
|
||||
}
|
||||
|
||||
void ProfileComparePage::refreshProfiles()
|
||||
{
|
||||
extractProfiles();
|
||||
|
|
@ -605,72 +659,146 @@ void ProfileComparePage::extractProfiles()
|
|||
double volMin[3], volMax[3];
|
||||
bg->worldBounds(volMin, volMax);
|
||||
const auto sp = bg->spacing();
|
||||
const double deg = m_angleSpin ? m_angleSpin->value() : 0.0;
|
||||
const bool oblique = std::abs(deg) >= 1e-6;
|
||||
|
||||
double rmin[3] = {volMin[0], volMin[1], volMin[2]};
|
||||
double rmax[3] = {volMax[0], volMax[1], volMax[2]};
|
||||
|
||||
auto intersectVisible = [&](int axis, std::initializer_list<SliceViewWidget*> views) {
|
||||
bool any = false;
|
||||
double lo = 0.0, hi = 0.0;
|
||||
for (SliceViewWidget* view : views) {
|
||||
double t0 = 0.0, t1 = 0.0;
|
||||
if (!view->visibleRangeForWorldAxis(axis, t0, t1))
|
||||
continue;
|
||||
if (!any) {
|
||||
lo = t0;
|
||||
hi = t1;
|
||||
any = true;
|
||||
} else {
|
||||
lo = std::max(lo, t0);
|
||||
hi = std::min(hi, t1);
|
||||
auto rayAabbRange = [&](const double dir[3], double& t0, double& t1) {
|
||||
t0 = -1e30;
|
||||
t1 = 1e30;
|
||||
auto clip = [&](double o, double d, double lo, double hi) {
|
||||
if (std::abs(d) < 1e-12) {
|
||||
if (o < lo || o > hi)
|
||||
t1 = -1e30;
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!any || hi <= lo)
|
||||
return;
|
||||
rmin[axis] = std::max(volMin[axis], lo);
|
||||
rmax[axis] = std::min(volMax[axis], hi);
|
||||
if (rmax[axis] <= rmin[axis]) {
|
||||
rmin[axis] = volMin[axis];
|
||||
rmax[axis] = volMax[axis];
|
||||
}
|
||||
double a = (lo - o) / d;
|
||||
double b = (hi - o) / d;
|
||||
if (a > b)
|
||||
std::swap(a, b);
|
||||
t0 = std::max(t0, a);
|
||||
t1 = std::min(t1, b);
|
||||
};
|
||||
clip(m_cursor.x, dir[0], volMin[0], volMax[0]);
|
||||
clip(m_cursor.y, dir[1], volMin[1], volMax[1]);
|
||||
clip(m_cursor.z, dir[2], volMin[2], volMax[2]);
|
||||
};
|
||||
|
||||
intersectVisible(0, {m_axial, m_coronal});
|
||||
intersectVisible(1, {m_axial, m_sagittal});
|
||||
intersectVisible(2, {m_sagittal, m_coronal});
|
||||
|
||||
auto sampleLine = [&](DoseImage::Pointer dose, int axis, QVector<double>& pos,
|
||||
QVector<double>& val) {
|
||||
auto sampleOblique = [&](DoseImage::Pointer dose, SliceViewWidget* view,
|
||||
QVector<double>& pos, QVector<double>& val) {
|
||||
pos.clear();
|
||||
val.clear();
|
||||
const double step = std::abs(sp[axis]);
|
||||
const double lo = rmin[axis];
|
||||
const double hi = rmax[axis];
|
||||
if (step < 1e-9 || hi < lo)
|
||||
if (!view)
|
||||
return;
|
||||
for (double t = lo; t <= hi + 0.5 * step; t += step) {
|
||||
double dir[3];
|
||||
view->profileDirectionWorld(dir);
|
||||
double tVol0 = 0, tVol1 = 0;
|
||||
rayAabbRange(dir, tVol0, tVol1);
|
||||
if (!(tVol1 > tVol0))
|
||||
return;
|
||||
double tVis0 = 0, tVis1 = 0;
|
||||
if (view->visibleRangeAlongProfile(tVis0, tVis1)) {
|
||||
tVol0 = std::max(tVol0, tVis0);
|
||||
tVol1 = std::min(tVol1, tVis1);
|
||||
}
|
||||
if (!(tVol1 > tVol0))
|
||||
return;
|
||||
const double step = std::max({std::abs(sp[0]), std::abs(sp[1]), std::abs(sp[2]), 0.5});
|
||||
for (double t = tVol0; t <= tVol1 + 0.5 * step; t += step) {
|
||||
pos.push_back(t);
|
||||
if (axis == 0)
|
||||
val.push_back(dose->sampleWorld(t, m_cursor.y, m_cursor.z));
|
||||
else if (axis == 1)
|
||||
val.push_back(dose->sampleWorld(m_cursor.x, t, m_cursor.z));
|
||||
else
|
||||
val.push_back(dose->sampleWorld(m_cursor.x, m_cursor.y, t));
|
||||
val.push_back(dose->sampleWorld(m_cursor.x + t * dir[0],
|
||||
m_cursor.y + t * dir[1],
|
||||
m_cursor.z + t * dir[2]));
|
||||
}
|
||||
};
|
||||
|
||||
for (const auto& img : m_manager->images()) {
|
||||
if (!img.profileVisible)
|
||||
continue;
|
||||
ProfileSeries sx, sy, sz;
|
||||
sx.name = sy.name = sz.name = img.dose->name();
|
||||
sx.color = sy.color = sz.color = img.dose->color();
|
||||
sampleLine(img.dose, 0, sx.positionsMm, sx.valuesGy);
|
||||
sampleLine(img.dose, 1, sy.positionsMm, sy.valuesGy);
|
||||
sampleLine(img.dose, 2, sz.positionsMm, sz.valuesGy);
|
||||
alongX.push_back(sx);
|
||||
alongY.push_back(sy);
|
||||
alongZ.push_back(sz);
|
||||
if (!oblique) {
|
||||
double rmin[3] = {volMin[0], volMin[1], volMin[2]};
|
||||
double rmax[3] = {volMax[0], volMax[1], volMax[2]};
|
||||
|
||||
auto intersectVisible = [&](int axis, std::initializer_list<SliceViewWidget*> views) {
|
||||
bool any = false;
|
||||
double lo = 0.0, hi = 0.0;
|
||||
for (SliceViewWidget* view : views) {
|
||||
double a = 0.0, b = 0.0;
|
||||
if (!view->visibleRangeForWorldAxis(axis, a, b))
|
||||
continue;
|
||||
if (!any) {
|
||||
lo = a;
|
||||
hi = b;
|
||||
any = true;
|
||||
} else {
|
||||
lo = std::max(lo, a);
|
||||
hi = std::min(hi, b);
|
||||
}
|
||||
}
|
||||
if (!any || hi <= lo)
|
||||
return;
|
||||
rmin[axis] = std::max(volMin[axis], lo);
|
||||
rmax[axis] = std::min(volMax[axis], hi);
|
||||
if (rmax[axis] <= rmin[axis]) {
|
||||
rmin[axis] = volMin[axis];
|
||||
rmax[axis] = volMax[axis];
|
||||
}
|
||||
};
|
||||
|
||||
intersectVisible(0, {m_axial, m_coronal});
|
||||
intersectVisible(1, {m_axial, m_sagittal});
|
||||
intersectVisible(2, {m_sagittal, m_coronal});
|
||||
|
||||
auto sampleLine = [&](DoseImage::Pointer dose, int axis, QVector<double>& pos,
|
||||
QVector<double>& val) {
|
||||
pos.clear();
|
||||
val.clear();
|
||||
const double step = std::abs(sp[axis]);
|
||||
const double lo = rmin[axis];
|
||||
const double hi = rmax[axis];
|
||||
if (step < 1e-9 || hi < lo)
|
||||
return;
|
||||
for (double t = lo; t <= hi + 0.5 * step; t += step) {
|
||||
pos.push_back(t);
|
||||
if (axis == 0)
|
||||
val.push_back(dose->sampleWorld(t, m_cursor.y, m_cursor.z));
|
||||
else if (axis == 1)
|
||||
val.push_back(dose->sampleWorld(m_cursor.x, t, m_cursor.z));
|
||||
else
|
||||
val.push_back(dose->sampleWorld(m_cursor.x, m_cursor.y, t));
|
||||
}
|
||||
};
|
||||
|
||||
for (const auto& img : m_manager->images()) {
|
||||
if (!img.profileVisible)
|
||||
continue;
|
||||
ProfileSeries sx, sy, sz;
|
||||
sx.name = sy.name = sz.name = img.dose->name();
|
||||
sx.color = sy.color = sz.color = img.dose->color();
|
||||
sampleLine(img.dose, 0, sx.positionsMm, sx.valuesGy);
|
||||
sampleLine(img.dose, 1, sy.positionsMm, sy.valuesGy);
|
||||
sampleLine(img.dose, 2, sz.positionsMm, sz.valuesGy);
|
||||
alongX.push_back(sx);
|
||||
alongY.push_back(sy);
|
||||
alongZ.push_back(sz);
|
||||
}
|
||||
|
||||
m_profAxial->setCursorMm(m_cursor.x);
|
||||
m_profSagittal->setCursorMm(m_cursor.y);
|
||||
m_profCoronal->setCursorMm(m_cursor.z);
|
||||
} else {
|
||||
for (const auto& img : m_manager->images()) {
|
||||
if (!img.profileVisible)
|
||||
continue;
|
||||
ProfileSeries sx, sy, sz;
|
||||
sx.name = sy.name = sz.name = img.dose->name();
|
||||
sx.color = sy.color = sz.color = img.dose->color();
|
||||
sampleOblique(img.dose, m_axial, sx.positionsMm, sx.valuesGy);
|
||||
sampleOblique(img.dose, m_sagittal, sy.positionsMm, sy.valuesGy);
|
||||
sampleOblique(img.dose, m_coronal, sz.positionsMm, sz.valuesGy);
|
||||
alongX.push_back(sx);
|
||||
alongY.push_back(sy);
|
||||
alongZ.push_back(sz);
|
||||
}
|
||||
m_profAxial->setCursorMm(0.0);
|
||||
m_profSagittal->setCursorMm(0.0);
|
||||
m_profCoronal->setCursorMm(0.0);
|
||||
}
|
||||
|
||||
const double yMin = static_cast<double>(bg->minValue());
|
||||
|
|
@ -680,9 +808,6 @@ void ProfileComparePage::extractProfiles()
|
|||
m_profCoronal->setYRange(yMin, yMax);
|
||||
|
||||
m_profAxial->setSeries(alongX);
|
||||
m_profAxial->setCursorMm(m_cursor.x);
|
||||
m_profSagittal->setSeries(alongY);
|
||||
m_profSagittal->setCursorMm(m_cursor.y);
|
||||
m_profCoronal->setSeries(alongZ);
|
||||
m_profCoronal->setCursorMm(m_cursor.z);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ class ImageListPanel;
|
|||
class QComboBox;
|
||||
class QSlider;
|
||||
class QLabel;
|
||||
class QDoubleSpinBox;
|
||||
class QDragEnterEvent;
|
||||
class QDropEvent;
|
||||
|
||||
|
|
@ -37,9 +38,11 @@ private slots:
|
|||
void onColorMapChanged(int index);
|
||||
void onSliceSliderChanged(int value);
|
||||
void onCursorChanged(Vec3d world);
|
||||
void onProfileAngleChanged(double deg);
|
||||
void refreshImageSelectors();
|
||||
void refreshViews();
|
||||
void refreshProfiles();
|
||||
void updateProfileTitles();
|
||||
void syncSliceSliders();
|
||||
void onZoomScaleChanged(double parallelScale);
|
||||
void onPanWorldDelta(double dx, double dy, double dz);
|
||||
|
|
@ -76,6 +79,7 @@ private:
|
|||
QComboBox* m_colorMapCombo = nullptr;
|
||||
QSlider* m_opacitySlider = nullptr;
|
||||
QLabel* m_opacityLabel = nullptr;
|
||||
QDoubleSpinBox* m_angleSpin = nullptr;
|
||||
|
||||
QSlider* m_sliderAxial = nullptr;
|
||||
QSlider* m_sliderSagittal = nullptr;
|
||||
|
|
|
|||
|
|
@ -52,6 +52,35 @@ vtkSmartPointer<vtkActor2D> makeCrossActor(const double rgb[3])
|
|||
return actor;
|
||||
}
|
||||
|
||||
// Points: 0=line start, 1=line end / arrow tip, 2=wingL, 3=wingR
|
||||
vtkSmartPointer<vtkActor2D> makeObliqueActor()
|
||||
{
|
||||
auto poly = vtkSmartPointer<vtkPolyData>::New();
|
||||
auto pts = vtkSmartPointer<vtkPoints>::New();
|
||||
for (int i = 0; i < 4; ++i)
|
||||
pts->InsertNextPoint(0, 0, 0);
|
||||
auto lines = vtkSmartPointer<vtkCellArray>::New();
|
||||
lines->InsertNextCell(2);
|
||||
lines->InsertCellPoint(0);
|
||||
lines->InsertCellPoint(1);
|
||||
lines->InsertNextCell(2);
|
||||
lines->InsertCellPoint(1);
|
||||
lines->InsertCellPoint(2);
|
||||
lines->InsertNextCell(2);
|
||||
lines->InsertCellPoint(1);
|
||||
lines->InsertCellPoint(3);
|
||||
poly->SetPoints(pts);
|
||||
poly->SetLines(lines);
|
||||
auto mapper = vtkSmartPointer<vtkPolyDataMapper2D>::New();
|
||||
mapper->SetInputData(poly);
|
||||
auto actor = vtkSmartPointer<vtkActor2D>::New();
|
||||
actor->SetMapper(mapper);
|
||||
actor->GetProperty()->SetColor(0.15, 0.95, 0.35);
|
||||
actor->GetProperty()->SetLineWidth(2.2);
|
||||
actor->SetVisibility(0);
|
||||
return actor;
|
||||
}
|
||||
|
||||
QLabel* makeEdgeLabel(QWidget* parent, const QString& text, const QColor& color)
|
||||
{
|
||||
auto* lab = new QLabel(text, parent);
|
||||
|
|
@ -116,8 +145,10 @@ SliceViewWidget::SliceViewWidget(ViewAxis axis, QWidget* parent)
|
|||
axisColorRgb(ViewAxis::Sagittal, rgbV);
|
||||
m_crossH = makeCrossActor(rgbH);
|
||||
m_crossV = makeCrossActor(rgbV);
|
||||
m_oblique = makeObliqueActor();
|
||||
m_renderer->AddActor2D(m_crossH);
|
||||
m_renderer->AddActor2D(m_crossV);
|
||||
m_renderer->AddActor2D(m_oblique);
|
||||
|
||||
const QColor ac = axisColor(m_axis);
|
||||
QString top, bottom, left, right;
|
||||
|
|
@ -125,20 +156,20 @@ SliceViewWidget::SliceViewWidget(ViewAxis axis, QWidget* parent)
|
|||
case ViewAxis::Axial:
|
||||
top = QStringLiteral("A");
|
||||
bottom = QStringLiteral("P");
|
||||
left = QStringLiteral("L");
|
||||
right = QStringLiteral("R");
|
||||
left = QStringLiteral("R");
|
||||
right = QStringLiteral("L");
|
||||
break;
|
||||
case ViewAxis::Sagittal:
|
||||
top = QStringLiteral("S");
|
||||
bottom = QStringLiteral("I");
|
||||
left = QStringLiteral("P");
|
||||
right = QStringLiteral("A");
|
||||
left = QStringLiteral("A");
|
||||
right = QStringLiteral("P");
|
||||
break;
|
||||
case ViewAxis::Coronal:
|
||||
top = QStringLiteral("S");
|
||||
bottom = QStringLiteral("I");
|
||||
left = QStringLiteral("L");
|
||||
right = QStringLiteral("R");
|
||||
left = QStringLiteral("R");
|
||||
right = QStringLiteral("L");
|
||||
break;
|
||||
}
|
||||
m_labelTop = makeEdgeLabel(this, top, ac);
|
||||
|
|
@ -297,27 +328,27 @@ void SliceViewWidget::setColorMap(DoseColorMap map)
|
|||
|
||||
void SliceViewWidget::viewDirections(double u[3], double v[3], double n[3]) const
|
||||
{
|
||||
// 物理轴按 LPS 显示(+X=L +Y=P +Z=S)。MHD 虽写 RAI,但按 LPS 才能上A下P。
|
||||
// 轴位:上A下P左L右R —— 「屁股朝天」= P 在上,必须用 v=-Y(A)
|
||||
// 物理轴按 LPS(+X=L +Y=P +Z=S)。
|
||||
// 轴位/冠状:放射学惯例(面朝患者)—— 屏左=患者右(R),屏右=患者左(L)。
|
||||
for (int i = 0; i < 3; ++i)
|
||||
u[i] = v[i] = n[i] = 0.0;
|
||||
switch (m_axis) {
|
||||
case ViewAxis::Axial:
|
||||
u[0] = -1.0; // 屏右 = R = -X
|
||||
u[0] = 1.0; // 屏右 = L = +X;屏左 = R = -X
|
||||
v[1] = -1.0; // 屏上 = A = -Y(+Y 是屁股 P)
|
||||
n[2] = 1.0; // 从头(S)往脚(I)看
|
||||
break;
|
||||
case ViewAxis::Sagittal:
|
||||
// 从患者右看:上S下I 左P右A
|
||||
u[1] = -1.0; // 右 = A = -Y
|
||||
// 与 3D Slicer 一致:上S下I,屏左A屏右P
|
||||
u[1] = 1.0; // 屏右 = P = +Y;屏左 = A = -Y
|
||||
v[2] = 1.0; // 上 = S = +Z
|
||||
n[0] = -1.0; // 患者右 = -X,从右侧看
|
||||
n[0] = -1.0; // 从患者右侧看
|
||||
break;
|
||||
case ViewAxis::Coronal:
|
||||
// 面朝患者、约定俗成:上S下I 左L右R(图像水平翻转)
|
||||
u[0] = -1.0; // 屏右 = R = -X;屏左 = L = +X
|
||||
// 面朝患者:上S下I,屏左R屏右L
|
||||
u[0] = 1.0; // 屏右 = L = +X;屏左 = R = -X
|
||||
v[2] = 1.0; // 上 = S = +Z
|
||||
n[1] = -1.0; // 从 A(-Y) 看向 P
|
||||
n[1] = -1.0; // 从 A 看向 P
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
|
@ -340,6 +371,46 @@ void SliceViewWidget::inPlaneWorldAxes(int& uAxis, int& vAxis) const
|
|||
}
|
||||
}
|
||||
|
||||
void SliceViewWidget::setProfileAngleDeg(double deg)
|
||||
{
|
||||
if (!std::isfinite(deg))
|
||||
deg = 0.0;
|
||||
// wrap to (-180, 180]
|
||||
while (deg > 180.0)
|
||||
deg -= 360.0;
|
||||
while (deg <= -180.0)
|
||||
deg += 360.0;
|
||||
if (std::abs(deg - m_profileAngleDeg) < 1e-9)
|
||||
return;
|
||||
m_profileAngleDeg = deg;
|
||||
updateCrosshair();
|
||||
if (m_vtkWidget && m_vtkWidget->GetRenderWindow())
|
||||
m_vtkWidget->GetRenderWindow()->Render();
|
||||
}
|
||||
|
||||
void SliceViewWidget::profileDirectionWorld(double dir[3]) const
|
||||
{
|
||||
double u[3], v[3], n[3];
|
||||
viewDirections(u, v, n);
|
||||
const double rad = m_profileAngleDeg * 3.14159265358979323846 / 180.0;
|
||||
const double c = std::cos(rad);
|
||||
const double s = std::sin(rad);
|
||||
// Coronal: 0° along SI (V); Axial/Sagittal: 0° along screen-horizontal (U).
|
||||
if (m_axis == ViewAxis::Coronal) {
|
||||
for (int i = 0; i < 3; ++i)
|
||||
dir[i] = -s * u[i] + c * v[i];
|
||||
} else {
|
||||
for (int i = 0; i < 3; ++i)
|
||||
dir[i] = c * u[i] + s * v[i];
|
||||
}
|
||||
const double len = std::sqrt(dir[0] * dir[0] + dir[1] * dir[1] + dir[2] * dir[2]);
|
||||
if (len > 1e-12) {
|
||||
dir[0] /= len;
|
||||
dir[1] /= len;
|
||||
dir[2] /= len;
|
||||
}
|
||||
}
|
||||
|
||||
void SliceViewWidget::updateOrientationLabels()
|
||||
{
|
||||
if (!m_labelTop)
|
||||
|
|
@ -628,6 +699,66 @@ bool SliceViewWidget::visibleRangeForWorldAxis(int worldAxis, double& rmin, doub
|
|||
return true;
|
||||
}
|
||||
|
||||
bool SliceViewWidget::visibleRangeAlongProfile(double& tMin, double& tMax) const
|
||||
{
|
||||
if (!m_renderer || !m_vtkWidget || !m_cameraReady)
|
||||
return false;
|
||||
auto* cam = m_renderer->GetActiveCamera();
|
||||
if (!cam)
|
||||
return false;
|
||||
|
||||
const double halfH = cam->GetParallelScale();
|
||||
const double aspect = static_cast<double>(std::max(1, m_vtkWidget->width()))
|
||||
/ static_cast<double>(std::max(1, m_vtkWidget->height()));
|
||||
const double halfW = halfH * aspect;
|
||||
double fp[3];
|
||||
cam->GetFocalPoint(fp);
|
||||
|
||||
double u[3], v[3], n[3];
|
||||
viewDirections(u, v, n);
|
||||
const Vec3d ori = sliceOrigin();
|
||||
|
||||
// Cursor in UV of this slice.
|
||||
const double dx = m_cursor.x - ori.x;
|
||||
const double dy = m_cursor.y - ori.y;
|
||||
const double dz = m_cursor.z - ori.z;
|
||||
const double cu = dx * u[0] + dy * u[1] + dz * u[2];
|
||||
const double cv = dx * v[0] + dy * v[1] + dz * v[2];
|
||||
|
||||
double dirW[3];
|
||||
profileDirectionWorld(dirW);
|
||||
const double du = dirW[0] * u[0] + dirW[1] * u[1] + dirW[2] * u[2];
|
||||
const double dv = dirW[0] * v[0] + dirW[1] * v[1] + dirW[2] * v[2];
|
||||
const double dlen2 = du * du + dv * dv;
|
||||
if (dlen2 < 1e-16)
|
||||
return false;
|
||||
|
||||
// Visible rectangle in UV (same as displayToWorld).
|
||||
const double u0 = fp[0] - halfW;
|
||||
const double u1 = fp[0] + halfW;
|
||||
const double v0 = fp[1] - halfH;
|
||||
const double v1 = fp[1] + halfH;
|
||||
|
||||
tMin = -1e30;
|
||||
tMax = 1e30;
|
||||
auto clip = [&](double p, double d, double lo, double hi) {
|
||||
if (std::abs(d) < 1e-12) {
|
||||
if (p < lo || p > hi)
|
||||
tMax = -1e30;
|
||||
return;
|
||||
}
|
||||
double a = (lo - p) / d;
|
||||
double b = (hi - p) / d;
|
||||
if (a > b)
|
||||
std::swap(a, b);
|
||||
tMin = std::max(tMin, a);
|
||||
tMax = std::min(tMax, b);
|
||||
};
|
||||
clip(cu, du, u0, u1);
|
||||
clip(cv, dv, v0, v1);
|
||||
return tMax > tMin;
|
||||
}
|
||||
|
||||
bool SliceViewWidget::displayToWorld(int displayX, int displayY, Vec3d& out) const
|
||||
{
|
||||
if (!m_renderer || !m_cameraReady)
|
||||
|
|
@ -824,6 +955,90 @@ void SliceViewWidget::updateCrosshair()
|
|||
axisColorRgb(colorForLineDirection(v), rgbV);
|
||||
setLine(m_crossH, 0, cy, w, cy, rgbU);
|
||||
setLine(m_crossV, cx, 0, cx, h, rgbV);
|
||||
|
||||
// Green oblique profile line (hidden at 0°).
|
||||
if (!m_oblique)
|
||||
return;
|
||||
if (std::abs(m_profileAngleDeg) < 1e-6) {
|
||||
m_oblique->SetVisibility(0);
|
||||
return;
|
||||
}
|
||||
|
||||
double dirW[3];
|
||||
profileDirectionWorld(dirW);
|
||||
// Renderer world == in-plane UV; project world dir onto U/V.
|
||||
const double tipLen = 20.0; // mm
|
||||
const double du = dirW[0] * u[0] + dirW[1] * u[1] + dirW[2] * u[2];
|
||||
const double dv = dirW[0] * v[0] + dirW[1] * v[1] + dirW[2] * v[2];
|
||||
m_renderer->SetWorldPoint(tu + tipLen * du, tv + tipLen * dv, 0.0, 1.0);
|
||||
m_renderer->WorldToDisplay();
|
||||
double tipDisp[3];
|
||||
m_renderer->GetDisplayPoint(tipDisp);
|
||||
double ddx = tipDisp[0] - cx;
|
||||
double ddy = tipDisp[1] - cy;
|
||||
const double dlen = std::sqrt(ddx * ddx + ddy * ddy);
|
||||
if (dlen < 1e-6) {
|
||||
m_oblique->SetVisibility(0);
|
||||
return;
|
||||
}
|
||||
ddx /= dlen;
|
||||
ddy /= dlen;
|
||||
|
||||
// Clip infinite line through (cx,cy) along (ddx,ddy) to the widget rectangle.
|
||||
auto clipT = [&](double dx, double dy, double& tEnter, double& tLeave) {
|
||||
tEnter = -1e30;
|
||||
tLeave = 1e30;
|
||||
auto clip = [&](double p, double d, double minB, double maxB) {
|
||||
if (std::abs(d) < 1e-12) {
|
||||
if (p < minB || p > maxB)
|
||||
tLeave = -1e30; // empty
|
||||
return;
|
||||
}
|
||||
double ta = (minB - p) / d;
|
||||
double tb = (maxB - p) / d;
|
||||
if (ta > tb)
|
||||
std::swap(ta, tb);
|
||||
tEnter = std::max(tEnter, ta);
|
||||
tLeave = std::min(tLeave, tb);
|
||||
};
|
||||
clip(cx, dx, 0.0, static_cast<double>(w));
|
||||
clip(cy, dy, 0.0, static_cast<double>(h));
|
||||
};
|
||||
double tEnter = 0, tLeave = 0;
|
||||
clipT(ddx, ddy, tEnter, tLeave);
|
||||
if (!(tLeave > tEnter)) {
|
||||
m_oblique->SetVisibility(0);
|
||||
return;
|
||||
}
|
||||
|
||||
const double xA = cx + tEnter * ddx;
|
||||
const double yA = cy + tEnter * ddy;
|
||||
const double xB = cx + tLeave * ddx;
|
||||
const double yB = cy + tLeave * ddy;
|
||||
// Arrow at +dir end (tLeave).
|
||||
const double tipX = xB;
|
||||
const double tipY = yB;
|
||||
const double startX = xA;
|
||||
const double startY = yA;
|
||||
|
||||
const double arrow = 10.0;
|
||||
const double nx = -ddy;
|
||||
const double ny = ddx;
|
||||
const double wx0 = tipX - arrow * ddx + 0.5 * arrow * nx;
|
||||
const double wy0 = tipY - arrow * ddy + 0.5 * arrow * ny;
|
||||
const double wx1 = tipX - arrow * ddx - 0.5 * arrow * nx;
|
||||
const double wy1 = tipY - arrow * ddy - 0.5 * arrow * ny;
|
||||
|
||||
auto* mapper = vtkPolyDataMapper2D::SafeDownCast(m_oblique->GetMapper());
|
||||
auto* poly = vtkPolyData::SafeDownCast(mapper->GetInput());
|
||||
auto* pts = poly->GetPoints();
|
||||
pts->SetPoint(0, startX, startY, 0);
|
||||
pts->SetPoint(1, tipX, tipY, 0);
|
||||
pts->SetPoint(2, wx0, wy0, 0);
|
||||
pts->SetPoint(3, wx1, wy1, 0);
|
||||
pts->Modified();
|
||||
poly->Modified();
|
||||
m_oblique->SetVisibility(1);
|
||||
}
|
||||
|
||||
void SliceViewWidget::updatePickOverlay(const QPoint& pos)
|
||||
|
|
|
|||
|
|
@ -29,6 +29,14 @@ public:
|
|||
void setCursorWorld(const Vec3d& world);
|
||||
Vec3d cursorWorld() const { return m_cursor; }
|
||||
|
||||
// In-plane profile angle (degrees). 0 = axis-aligned (no green overlay).
|
||||
// Axial/Sagittal: CCW from screen-horizontal (U).
|
||||
// Coronal: CCW from screen-vertical SI (V), so 0° matches along-Z.
|
||||
void setProfileAngleDeg(double deg);
|
||||
double profileAngleDeg() const { return m_profileAngleDeg; }
|
||||
// Unit world direction used for oblique profile sampling on this view.
|
||||
void profileDirectionWorld(double dir[3]) const;
|
||||
|
||||
void resetViewToVolumeCenter();
|
||||
|
||||
void setWindowLevel(double window, double level);
|
||||
|
|
@ -43,6 +51,8 @@ public:
|
|||
void setColorBarVisible(bool visible, double vmin = 0.0, double vmax = 2.0);
|
||||
|
||||
bool visibleRangeForWorldAxis(int worldAxis, double& rmin, double& rmax) const;
|
||||
// Signed distance range along profileDirectionWorld() from cursor, clipped to current FOV.
|
||||
bool visibleRangeAlongProfile(double& tMin, double& tMax) const;
|
||||
|
||||
double parallelScale() const;
|
||||
void setParallelScaleShared(double scale);
|
||||
|
|
@ -120,6 +130,8 @@ private:
|
|||
vtkSmartPointer<vtkImageActor> m_actorFg;
|
||||
vtkSmartPointer<vtkActor2D> m_crossH;
|
||||
vtkSmartPointer<vtkActor2D> m_crossV;
|
||||
vtkSmartPointer<vtkActor2D> m_oblique; // green angled profile line + arrow
|
||||
double m_profileAngleDeg = 0.0;
|
||||
|
||||
bool m_leftDown = false;
|
||||
bool m_rightDown = false;
|
||||
|
|
|
|||
|
|
@ -15,10 +15,10 @@ static void sampleSlice(const DoseImage::Pointer& dose, int axisCode)
|
|||
const double cz = 0.5 * (bmin[2] + bmax[2]);
|
||||
|
||||
double u[3] = {0, 0, 0}, v[3] = {0, 0, 0};
|
||||
if (axisCode == 0) { // axial
|
||||
u[0] = -1; v[1] = -1;
|
||||
} else if (axisCode == 1) { // sag
|
||||
u[1] = -1; v[2] = 1;
|
||||
if (axisCode == 0) { // axial — 屏右=L=+X
|
||||
u[0] = 1; v[1] = -1;
|
||||
} else if (axisCode == 1) { // sag — 屏右=P=+Y
|
||||
u[1] = 1; v[2] = 1;
|
||||
} else {
|
||||
u[0] = 1; v[2] = 1;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue