Feat:增加斜向角profile

master
hssheng 2026-07-20 17:43:04 +08:00
parent 6cd6e39632
commit 79ad28fd58
5 changed files with 434 additions and 78 deletions

View File

@ -9,6 +9,7 @@
#include <QComboBox> #include <QComboBox>
#include <QSlider> #include <QSlider>
#include <QLabel> #include <QLabel>
#include <QDoubleSpinBox>
#include <QToolBar> #include <QToolBar>
#include <QHBoxLayout> #include <QHBoxLayout>
#include <QVBoxLayout> #include <QVBoxLayout>
@ -27,6 +28,7 @@
#include <algorithm> #include <algorithm>
#include <initializer_list> #include <initializer_list>
#include <cmath>
ProfileComparePage::ProfileComparePage(QWidget* parent) ProfileComparePage::ProfileComparePage(QWidget* parent)
: QWidget(parent) : QWidget(parent)
@ -83,6 +85,22 @@ void ProfileComparePage::buildUi()
m_colorMapCombo->setMinimumWidth(110); m_colorMapCombo->setMinimumWidth(110);
m_colorMapCombo->blockSignals(false); m_colorMapCombo->blockSignals(false);
tb->addWidget(m_colorMapCombo); 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); tb->addStretch(1);
pageLay->addWidget(topBar); pageLay->addWidget(topBar);
@ -93,6 +111,8 @@ void ProfileComparePage::buildUi()
connect(m_opacitySlider, &QSlider::valueChanged, this, &ProfileComparePage::onOpacityChanged); connect(m_opacitySlider, &QSlider::valueChanged, this, &ProfileComparePage::onOpacityChanged);
connect(m_colorMapCombo, QOverload<int>::of(&QComboBox::currentIndexChanged), connect(m_colorMapCombo, QOverload<int>::of(&QComboBox::currentIndexChanged),
this, &ProfileComparePage::onColorMapChanged); this, &ProfileComparePage::onColorMapChanged);
connect(m_angleSpin, QOverload<double>::of(&QDoubleSpinBox::valueChanged),
this, &ProfileComparePage::onProfileAngleChanged);
auto* central = new QWidget(this); auto* central = new QWidget(this);
auto* root = new QHBoxLayout(central); auto* root = new QHBoxLayout(central);
@ -147,9 +167,10 @@ void ProfileComparePage::buildUi()
right->addLayout(profRow, 2); right->addLayout(profRow, 2);
m_statusLabel = new QLabel( 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); m_statusLabel->setContentsMargins(8, 4, 8, 4);
pageLay->addWidget(m_statusLabel); pageLay->addWidget(m_statusLabel);
updateProfileTitles();
} }
QString ProfileComparePage::defaultOpenDir() const QString ProfileComparePage::defaultOpenDir() const
@ -586,6 +607,39 @@ void ProfileComparePage::setCursor(const Vec3d& world, bool fromSlider)
refreshProfiles(); 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() void ProfileComparePage::refreshProfiles()
{ {
extractProfiles(); extractProfiles();
@ -605,7 +659,59 @@ void ProfileComparePage::extractProfiles()
double volMin[3], volMax[3]; double volMin[3], volMax[3];
bg->worldBounds(volMin, volMax); bg->worldBounds(volMin, volMax);
const auto sp = bg->spacing(); const auto sp = bg->spacing();
const double deg = m_angleSpin ? m_angleSpin->value() : 0.0;
const bool oblique = std::abs(deg) >= 1e-6;
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;
}
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]);
};
auto sampleOblique = [&](DoseImage::Pointer dose, SliceViewWidget* view,
QVector<double>& pos, QVector<double>& val) {
pos.clear();
val.clear();
if (!view)
return;
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);
val.push_back(dose->sampleWorld(m_cursor.x + t * dir[0],
m_cursor.y + t * dir[1],
m_cursor.z + t * dir[2]));
}
};
if (!oblique) {
double rmin[3] = {volMin[0], volMin[1], volMin[2]}; double rmin[3] = {volMin[0], volMin[1], volMin[2]};
double rmax[3] = {volMax[0], volMax[1], volMax[2]}; double rmax[3] = {volMax[0], volMax[1], volMax[2]};
@ -613,16 +719,16 @@ void ProfileComparePage::extractProfiles()
bool any = false; bool any = false;
double lo = 0.0, hi = 0.0; double lo = 0.0, hi = 0.0;
for (SliceViewWidget* view : views) { for (SliceViewWidget* view : views) {
double t0 = 0.0, t1 = 0.0; double a = 0.0, b = 0.0;
if (!view->visibleRangeForWorldAxis(axis, t0, t1)) if (!view->visibleRangeForWorldAxis(axis, a, b))
continue; continue;
if (!any) { if (!any) {
lo = t0; lo = a;
hi = t1; hi = b;
any = true; any = true;
} else { } else {
lo = std::max(lo, t0); lo = std::max(lo, a);
hi = std::min(hi, t1); hi = std::min(hi, b);
} }
} }
if (!any || hi <= lo) if (!any || hi <= lo)
@ -673,6 +779,28 @@ void ProfileComparePage::extractProfiles()
alongZ.push_back(sz); 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()); const double yMin = static_cast<double>(bg->minValue());
const double yMax = static_cast<double>(bg->maxValue()); const double yMax = static_cast<double>(bg->maxValue());
m_profAxial->setYRange(yMin, yMax); m_profAxial->setYRange(yMin, yMax);
@ -680,9 +808,6 @@ void ProfileComparePage::extractProfiles()
m_profCoronal->setYRange(yMin, yMax); m_profCoronal->setYRange(yMin, yMax);
m_profAxial->setSeries(alongX); m_profAxial->setSeries(alongX);
m_profAxial->setCursorMm(m_cursor.x);
m_profSagittal->setSeries(alongY); m_profSagittal->setSeries(alongY);
m_profSagittal->setCursorMm(m_cursor.y);
m_profCoronal->setSeries(alongZ); m_profCoronal->setSeries(alongZ);
m_profCoronal->setCursorMm(m_cursor.z);
} }

View File

@ -13,6 +13,7 @@ class ImageListPanel;
class QComboBox; class QComboBox;
class QSlider; class QSlider;
class QLabel; class QLabel;
class QDoubleSpinBox;
class QDragEnterEvent; class QDragEnterEvent;
class QDropEvent; class QDropEvent;
@ -37,9 +38,11 @@ private slots:
void onColorMapChanged(int index); void onColorMapChanged(int index);
void onSliceSliderChanged(int value); void onSliceSliderChanged(int value);
void onCursorChanged(Vec3d world); void onCursorChanged(Vec3d world);
void onProfileAngleChanged(double deg);
void refreshImageSelectors(); void refreshImageSelectors();
void refreshViews(); void refreshViews();
void refreshProfiles(); void refreshProfiles();
void updateProfileTitles();
void syncSliceSliders(); void syncSliceSliders();
void onZoomScaleChanged(double parallelScale); void onZoomScaleChanged(double parallelScale);
void onPanWorldDelta(double dx, double dy, double dz); void onPanWorldDelta(double dx, double dy, double dz);
@ -76,6 +79,7 @@ private:
QComboBox* m_colorMapCombo = nullptr; QComboBox* m_colorMapCombo = nullptr;
QSlider* m_opacitySlider = nullptr; QSlider* m_opacitySlider = nullptr;
QLabel* m_opacityLabel = nullptr; QLabel* m_opacityLabel = nullptr;
QDoubleSpinBox* m_angleSpin = nullptr;
QSlider* m_sliderAxial = nullptr; QSlider* m_sliderAxial = nullptr;
QSlider* m_sliderSagittal = nullptr; QSlider* m_sliderSagittal = nullptr;

View File

@ -52,6 +52,35 @@ vtkSmartPointer<vtkActor2D> makeCrossActor(const double rgb[3])
return actor; 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) QLabel* makeEdgeLabel(QWidget* parent, const QString& text, const QColor& color)
{ {
auto* lab = new QLabel(text, parent); auto* lab = new QLabel(text, parent);
@ -116,8 +145,10 @@ SliceViewWidget::SliceViewWidget(ViewAxis axis, QWidget* parent)
axisColorRgb(ViewAxis::Sagittal, rgbV); axisColorRgb(ViewAxis::Sagittal, rgbV);
m_crossH = makeCrossActor(rgbH); m_crossH = makeCrossActor(rgbH);
m_crossV = makeCrossActor(rgbV); m_crossV = makeCrossActor(rgbV);
m_oblique = makeObliqueActor();
m_renderer->AddActor2D(m_crossH); m_renderer->AddActor2D(m_crossH);
m_renderer->AddActor2D(m_crossV); m_renderer->AddActor2D(m_crossV);
m_renderer->AddActor2D(m_oblique);
const QColor ac = axisColor(m_axis); const QColor ac = axisColor(m_axis);
QString top, bottom, left, right; QString top, bottom, left, right;
@ -125,20 +156,20 @@ SliceViewWidget::SliceViewWidget(ViewAxis axis, QWidget* parent)
case ViewAxis::Axial: case ViewAxis::Axial:
top = QStringLiteral("A"); top = QStringLiteral("A");
bottom = QStringLiteral("P"); bottom = QStringLiteral("P");
left = QStringLiteral("L"); left = QStringLiteral("R");
right = QStringLiteral("R"); right = QStringLiteral("L");
break; break;
case ViewAxis::Sagittal: case ViewAxis::Sagittal:
top = QStringLiteral("S"); top = QStringLiteral("S");
bottom = QStringLiteral("I"); bottom = QStringLiteral("I");
left = QStringLiteral("P"); left = QStringLiteral("A");
right = QStringLiteral("A"); right = QStringLiteral("P");
break; break;
case ViewAxis::Coronal: case ViewAxis::Coronal:
top = QStringLiteral("S"); top = QStringLiteral("S");
bottom = QStringLiteral("I"); bottom = QStringLiteral("I");
left = QStringLiteral("L"); left = QStringLiteral("R");
right = QStringLiteral("R"); right = QStringLiteral("L");
break; break;
} }
m_labelTop = makeEdgeLabel(this, top, ac); 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 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 // 物理轴按 LPS+X=L +Y=P +Z=S
// 轴位上A下P左L右R —— 「屁股朝天」= P 在上,必须用 v=-YA // 轴位/冠状:放射学惯例(面朝患者)—— 屏左=患者右(R),屏右=患者左(L)。
for (int i = 0; i < 3; ++i) for (int i = 0; i < 3; ++i)
u[i] = v[i] = n[i] = 0.0; u[i] = v[i] = n[i] = 0.0;
switch (m_axis) { switch (m_axis) {
case ViewAxis::Axial: 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 v[1] = -1.0; // 屏上 = A = -Y+Y 是屁股 P
n[2] = 1.0; // 从头(S)往脚(I)看 n[2] = 1.0; // 从头(S)往脚(I)看
break; break;
case ViewAxis::Sagittal: case ViewAxis::Sagittal:
// 从患者右看上S下I 左P右A // 与 3D Slicer 一致上S下I屏左A屏右P
u[1] = -1.0; // 右 = A = -Y u[1] = 1.0; // = P = +Y屏左 = A = -Y
v[2] = 1.0; // 上 = S = +Z v[2] = 1.0; // 上 = S = +Z
n[0] = -1.0; // 患者右 = -X右侧看 n[0] = -1.0; // 患者右侧看
break; break;
case ViewAxis::Coronal: case ViewAxis::Coronal:
// 面朝患者、约定俗成上S下I 左L右R图像水平翻转 // 面朝患者上S下I屏左R屏右L
u[0] = -1.0; // 屏右 = R = -X屏左 = L = +X u[0] = 1.0; // 屏右 = L = +X屏左 = R = -X
v[2] = 1.0; // 上 = S = +Z v[2] = 1.0; // 上 = S = +Z
n[1] = -1.0; // 从 A(-Y) 看向 P n[1] = -1.0; // 从 A 看向 P
break; 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() void SliceViewWidget::updateOrientationLabels()
{ {
if (!m_labelTop) if (!m_labelTop)
@ -628,6 +699,66 @@ bool SliceViewWidget::visibleRangeForWorldAxis(int worldAxis, double& rmin, doub
return true; 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 bool SliceViewWidget::displayToWorld(int displayX, int displayY, Vec3d& out) const
{ {
if (!m_renderer || !m_cameraReady) if (!m_renderer || !m_cameraReady)
@ -824,6 +955,90 @@ void SliceViewWidget::updateCrosshair()
axisColorRgb(colorForLineDirection(v), rgbV); axisColorRgb(colorForLineDirection(v), rgbV);
setLine(m_crossH, 0, cy, w, cy, rgbU); setLine(m_crossH, 0, cy, w, cy, rgbU);
setLine(m_crossV, cx, 0, cx, h, rgbV); 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) void SliceViewWidget::updatePickOverlay(const QPoint& pos)

View File

@ -29,6 +29,14 @@ public:
void setCursorWorld(const Vec3d& world); void setCursorWorld(const Vec3d& world);
Vec3d cursorWorld() const { return m_cursor; } 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 resetViewToVolumeCenter();
void setWindowLevel(double window, double level); void setWindowLevel(double window, double level);
@ -43,6 +51,8 @@ public:
void setColorBarVisible(bool visible, double vmin = 0.0, double vmax = 2.0); void setColorBarVisible(bool visible, double vmin = 0.0, double vmax = 2.0);
bool visibleRangeForWorldAxis(int worldAxis, double& rmin, double& rmax) const; 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; double parallelScale() const;
void setParallelScaleShared(double scale); void setParallelScaleShared(double scale);
@ -120,6 +130,8 @@ private:
vtkSmartPointer<vtkImageActor> m_actorFg; vtkSmartPointer<vtkImageActor> m_actorFg;
vtkSmartPointer<vtkActor2D> m_crossH; vtkSmartPointer<vtkActor2D> m_crossH;
vtkSmartPointer<vtkActor2D> m_crossV; vtkSmartPointer<vtkActor2D> m_crossV;
vtkSmartPointer<vtkActor2D> m_oblique; // green angled profile line + arrow
double m_profileAngleDeg = 0.0;
bool m_leftDown = false; bool m_leftDown = false;
bool m_rightDown = false; bool m_rightDown = false;

View File

@ -15,10 +15,10 @@ static void sampleSlice(const DoseImage::Pointer& dose, int axisCode)
const double cz = 0.5 * (bmin[2] + bmax[2]); const double cz = 0.5 * (bmin[2] + bmax[2]);
double u[3] = {0, 0, 0}, v[3] = {0, 0, 0}; double u[3] = {0, 0, 0}, v[3] = {0, 0, 0};
if (axisCode == 0) { // axial if (axisCode == 0) { // axial — 屏右=L=+X
u[0] = -1; v[1] = -1; u[0] = 1; v[1] = -1;
} else if (axisCode == 1) { // sag } else if (axisCode == 1) { // sag — 屏右=P=+Y
u[1] = -1; v[2] = 1; u[1] = 1; v[2] = 1;
} else { } else {
u[0] = 1; v[2] = 1; u[0] = 1; v[2] = 1;
} }