From 79ad28fd58f665688fe54646a45edb246d2e0e90 Mon Sep 17 00:00:00 2001 From: hssheng Date: Mon, 20 Jul 2026 17:43:04 +0800 Subject: [PATCH] =?UTF-8?q?Feat=EF=BC=9A=E5=A2=9E=E5=8A=A0=E6=96=9C?= =?UTF-8?q?=E5=90=91=E8=A7=92profile?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ProfileComparePage.cpp | 243 +++++++++++++++++++++++++++--------- src/ProfileComparePage.h | 4 + src/SliceViewWidget.cpp | 245 ++++++++++++++++++++++++++++++++++--- src/SliceViewWidget.h | 12 ++ src/smoke_slice_test.cpp | 8 +- 5 files changed, 434 insertions(+), 78 deletions(-) diff --git a/src/ProfileComparePage.cpp b/src/ProfileComparePage.cpp index 779d6f5..6a2c019 100644 --- a/src/ProfileComparePage.cpp +++ b/src/ProfileComparePage.cpp @@ -9,6 +9,7 @@ #include #include #include +#include #include #include #include @@ -27,6 +28,7 @@ #include #include +#include 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::of(&QComboBox::currentIndexChanged), this, &ProfileComparePage::onColorMapChanged); + connect(m_angleSpin, QOverload::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 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& pos, - QVector& val) { + auto sampleOblique = [&](DoseImage::Pointer dose, SliceViewWidget* view, + QVector& pos, QVector& 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 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& pos, + QVector& 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(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); } diff --git a/src/ProfileComparePage.h b/src/ProfileComparePage.h index 63d335a..8676328 100644 --- a/src/ProfileComparePage.h +++ b/src/ProfileComparePage.h @@ -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; diff --git a/src/SliceViewWidget.cpp b/src/SliceViewWidget.cpp index b6b187e..dada1a0 100644 --- a/src/SliceViewWidget.cpp +++ b/src/SliceViewWidget.cpp @@ -52,6 +52,35 @@ vtkSmartPointer makeCrossActor(const double rgb[3]) return actor; } +// Points: 0=line start, 1=line end / arrow tip, 2=wingL, 3=wingR +vtkSmartPointer makeObliqueActor() +{ + auto poly = vtkSmartPointer::New(); + auto pts = vtkSmartPointer::New(); + for (int i = 0; i < 4; ++i) + pts->InsertNextPoint(0, 0, 0); + auto lines = vtkSmartPointer::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::New(); + mapper->SetInputData(poly); + auto actor = vtkSmartPointer::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(std::max(1, m_vtkWidget->width())) + / static_cast(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(w)); + clip(cy, dy, 0.0, static_cast(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) diff --git a/src/SliceViewWidget.h b/src/SliceViewWidget.h index e809a2f..2462062 100644 --- a/src/SliceViewWidget.h +++ b/src/SliceViewWidget.h @@ -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 m_actorFg; vtkSmartPointer m_crossH; vtkSmartPointer m_crossV; + vtkSmartPointer m_oblique; // green angled profile line + arrow + double m_profileAngleDeg = 0.0; bool m_leftDown = false; bool m_rightDown = false; diff --git a/src/smoke_slice_test.cpp b/src/smoke_slice_test.cpp index 7e54d91..34a546b 100644 --- a/src/smoke_slice_test.cpp +++ b/src/smoke_slice_test.cpp @@ -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; }