viewer: azimuthal ROI handles, and keep panning inside the ROI

Azimuthal ROIs now show discrete grab handles on the selected ROI: the
inner/outer arc (resize Q/d) and, for a sector, the two phi edges
(rotate). Editing is by clicking a handle, with a generous tolerance,
which fixes the previously near-impossible phi-edge grab. Clicking the
(large) interior of an azimuthal ROI now only selects it and lets the
view pan, instead of capturing the gesture.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-19 14:21:23 +02:00
co-authored by Claude Opus 4.8
parent 00b58cd426
commit d413af0e92
2 changed files with 74 additions and 39 deletions
+70 -39
View File
@@ -21,11 +21,6 @@
#include "JFJochSimpleImage.h"
// Constructor
static float AngularDistance_deg(float a, float b) {
float d = std::fabs(a - b);
return (d > 180.0f) ? 360.0f - d : d;
}
static bool InPhiSector(float phi, float phi_min, float phi_max) {
if (phi_min <= phi_max)
return phi >= phi_min && phi <= phi_max;
@@ -54,6 +49,31 @@ JFJochImage::ResizeHandle JFJochDiffractionImage::hitTestBoxHandle(const QRectF
return ResizeHandle::None;
}
void JFJochDiffractionImage::azimuthalHandles(const ROIAzimuthal &az, const DiffractionGeometry &geom,
QPointF &inner, QPointF &outer, QPointF &phimin, QPointF &phimax) const {
const float d2r = static_cast<float>(PI) / 180.0f;
const float phi0 = az.GetPhiMin_deg();
const float phi1 = az.GetPhiMax_deg();
const float mid_phi = az.HasPhi()
? (phi1 >= phi0 ? (phi0 + phi1) / 2.0f : std::fmod((phi0 + phi1 + 360.0f) / 2.0f, 360.0f))
: 0.0f;
const float r_inner = geom.ResToPxl(az.GetDMax_A());
const float r_outer = geom.ResToPxl(az.GetDMin_A());
const float d_mid = geom.PxlToRes((r_inner + r_outer) / 2.0f);
auto pt = [&](float d, float phi_deg) -> QPointF {
try {
auto [x, y] = geom.ResPhiToPxl(d, phi_deg * d2r);
return QPointF(x, y);
} catch (...) {
return QPointF(-1e9, -1e9); // off-image: never matches a handle hit-test
}
};
inner = pt(az.GetDMax_A(), mid_phi);
outer = pt(az.GetDMin_A(), mid_phi);
phimin = pt(d_mid, phi0);
phimax = pt(d_mid, phi1);
}
void JFJochDiffractionImage::mouseHover(QMouseEvent *event) {
auto coord = mapToScene(event->pos());
@@ -427,18 +447,26 @@ void JFJochDiffractionImage::DrawROIs() {
}
}
for (const auto &az : rois.azimuthal) {
for (const auto &az_committed : rois.azimuthal) {
QColor c = palette[color_index++ % palette_size];
const bool editing = az.GetName() == edit_name_.toStdString()
const bool selected = (QString::fromStdString(az_committed.GetName()) == selected_roi_);
const bool editing = az_committed.GetName() == edit_name_.toStdString()
&& (roi_edit_ == RoiEdit::AzimInner || roi_edit_ == RoiEdit::AzimOuter
|| roi_edit_ == RoiEdit::RotatePhiMin || roi_edit_ == RoiEdit::RotatePhiMax);
if (editing) {
const ROIAzimuthal edited = edit_has_phi_
? ROIAzimuthal(az.GetName(), edit_d_min_, edit_d_max_, edit_phi_min_, edit_phi_max_)
: ROIAzimuthal(az.GetName(), edit_d_min_, edit_d_max_);
DrawAzimuthalROI(edited, c, geom);
} else {
DrawAzimuthalROI(az, c, geom);
const ROIAzimuthal az = editing
? (edit_has_phi_ ? ROIAzimuthal(az_committed.GetName(), edit_d_min_, edit_d_max_, edit_phi_min_, edit_phi_max_)
: ROIAzimuthal(az_committed.GetName(), edit_d_min_, edit_d_max_))
: az_committed;
DrawAzimuthalROI(az, c, geom);
if (selected) {
QPointF inner, outer, pmin, pmax;
azimuthalHandles(az, geom, inner, outer, pmin, pmax);
draw_handle(inner, c);
draw_handle(outer, c);
if (az.HasPhi()) {
draw_handle(pmin, c);
draw_handle(pmax, c);
}
}
}
}
@@ -574,17 +602,20 @@ bool JFJochDiffractionImage::roiEditPress(const QPointF &scenePos) {
return true;
}
// Azimuthal: inner/outer arc resizes Q/d, radial edge rotates phi.
// Azimuthal: grab one of the discrete handles to resize Q/d (inner/outer arc) or
// rotate a phi edge. Larger tolerance than the thin arcs would give.
const qreal tol_h = 9.0 / std::sqrt(std::max(1e-4, scale_factor));
for (const auto &az : rois.azimuthal) {
const auto [bx, by] = geom.GetDirectBeam_pxl();
const double cursor_r = QLineF(QPointF(bx, by), scenePos).length();
const double r_inner = geom.ResToPxl(az.GetDMax_A()); // larger d -> smaller radius
const double r_outer = geom.ResToPxl(az.GetDMin_A());
const float phi = geom.Phi_rad(scenePos.x(), scenePos.y()) * 180.0f / static_cast<float>(PI);
QPointF inner, outer, pmin, pmax;
azimuthalHandles(az, geom, inner, outer, pmin, pmax);
auto grab = [&](const QPointF &h) { return QLineF(h, scenePos).length() <= tol_h; };
if (az.HasPhi() && !InPhiSector(phi, az.GetPhiMin_deg(), az.GetPhiMax_deg()))
continue;
if (cursor_r < r_inner - tol || cursor_r > r_outer + tol)
RoiEdit mode = RoiEdit::None;
if (grab(inner)) mode = RoiEdit::AzimInner;
else if (grab(outer)) mode = RoiEdit::AzimOuter;
else if (az.HasPhi() && grab(pmin)) mode = RoiEdit::RotatePhiMin;
else if (az.HasPhi() && grab(pmax)) mode = RoiEdit::RotatePhiMax;
if (mode == RoiEdit::None)
continue;
start(az.GetName());
@@ -593,24 +624,24 @@ bool JFJochDiffractionImage::roiEditPress(const QPointF &scenePos) {
edit_has_phi_ = az.HasPhi();
edit_phi_min_ = az.GetPhiMin_deg();
edit_phi_max_ = az.GetPhiMax_deg();
if (std::abs(cursor_r - r_inner) <= tol) {
roi_edit_ = RoiEdit::AzimInner;
} else if (std::abs(cursor_r - r_outer) <= tol) {
roi_edit_ = RoiEdit::AzimOuter;
} else if (az.HasPhi()) {
const float tol_deg = (cursor_r > 1.0) ? tol / cursor_r * 180.0f / static_cast<float>(PI) : 180.0f;
if (AngularDistance_deg(phi, az.GetPhiMin_deg()) <= tol_deg)
roi_edit_ = RoiEdit::RotatePhiMin;
else if (AngularDistance_deg(phi, az.GetPhiMax_deg()) <= tol_deg)
roi_edit_ = RoiEdit::RotatePhiMax;
else
roi_edit_ = RoiEdit::None; // interior: select only
} else {
roi_edit_ = RoiEdit::None;
}
roi_edit_ = mode;
return true;
}
// Inside an azimuthal ROI but not on a handle: select it and let the base pan
// (these ROIs are large and should not capture the panning gesture).
for (const auto &az : rois.azimuthal) {
const auto [bx, by] = geom.GetDirectBeam_pxl();
const double cursor_r = QLineF(QPointF(bx, by), scenePos).length();
const float phi = geom.Phi_rad(scenePos.x(), scenePos.y()) * 180.0f / static_cast<float>(PI);
if (cursor_r < geom.ResToPxl(az.GetDMax_A()) || cursor_r > geom.ResToPxl(az.GetDMin_A()))
continue;
if (az.HasPhi() && !InPhiSector(phi, az.GetPhiMin_deg(), az.GetPhiMax_deg()))
continue;
selected_roi_ = QString::fromStdString(az.GetName());
emit roiSelected(selected_roi_);
break;
}
return false;
}
@@ -40,6 +40,10 @@ private:
void keyPressEvent(QKeyEvent *event) override; // Delete removes the selected ROI
[[nodiscard]] ROIDefinition BuildEditedROIDefinition() const; // current ROIs with the edit applied
[[nodiscard]] ResizeHandle hitTestBoxHandle(const QRectF &r, const QPointF &p, qreal tol) const;
// Grab points for an azimuthal ROI: inner/outer arc (resize Q/d) and the two phi
// edges (rotate). The phi handles are only meaningful for a sector (HasPhi()).
void azimuthalHandles(const ROIAzimuthal &az, const DiffractionGeometry &geom,
QPointF &inner, QPointF &outer, QPointF &phimin, QPointF &phimax) const;
QString selected_roi_;
enum class RoiEdit { None, MoveBox, ResizeBox, MoveCircle, ResizeCircle,