use egui::{Pos2, Rect, Vec2};
use crate::core::marker::{Marker, MarkerConstraint, MarkerKind};
use crate::core::roi::{HandleKind, ManagedRoi, Roi, RoiEdge, RoiHandle, RoiInteractionMode};
use crate::core::transform::{Scale, Transform};
pub use crate::core::transform::{
FLOAT32_MINPOS, FLOAT32_SAFE_MAX, FLOAT32_SAFE_MIN, clamp_axis_limits,
};
pub type Limits = (f64, f64, f64, f64);
fn pan_axis(min: f64, max: f64, delta_px: f64, extent_px: f64, scale: Scale) -> (f64, f64) {
match scale {
Scale::Log10 if min > 0.0 && max > 0.0 => {
let log_min = min.log10();
let log_max = max.log10();
let d_log = delta_px * (log_max - log_min) / extent_px;
let new_min = 10f64.powf(log_min - d_log);
let new_max = 10f64.powf(log_max - d_log);
if new_min < FLOAT32_MINPOS || new_max > FLOAT32_SAFE_MAX {
(min, max)
} else {
(new_min, new_max)
}
}
_ => {
let offset = delta_px * (max - min) / extent_px;
let new_min = min - offset;
let new_max = max - offset;
if new_min < FLOAT32_SAFE_MIN || new_max > FLOAT32_SAFE_MAX {
(min, max)
} else {
(new_min, new_max)
}
}
}
}
pub fn pan(limits: Limits, area: Rect, delta_px: Vec2, x_scale: Scale, y_scale: Scale) -> Limits {
let (x_min, x_max, y_min, y_max) = limits;
let w = area.width().max(1.0) as f64;
let h = area.height().max(1.0) as f64;
let (new_x_min, new_x_max) = pan_axis(x_min, x_max, delta_px.x as f64, w, x_scale);
let (new_y_min, new_y_max) = pan_axis(y_min, y_max, -(delta_px.y as f64), h, y_scale);
(new_x_min, new_x_max, new_y_min, new_y_max)
}
fn scale1d_range(min: f64, max: f64, center: f64, scale: f64, is_log: bool) -> (f64, f64) {
let (mut min, mut center, mut max) = (min, center, max);
if is_log {
min = if min > 0.0 {
min.log10()
} else {
FLOAT32_MINPOS
};
center = if center > 0.0 {
center.log10()
} else {
FLOAT32_MINPOS
};
max = if max > 0.0 {
max.log10()
} else {
FLOAT32_MINPOS
};
}
if min == max {
return (min, max);
}
let offset = (center - min) / (max - min);
let range = (max - min) / scale;
let mut new_min = center - offset * range;
let mut new_max = center + (1.0 - offset) * range;
if is_log {
new_min = 10f64.powf(new_min).clamp(FLOAT32_MINPOS, FLOAT32_SAFE_MAX);
new_max = 10f64.powf(new_max).clamp(FLOAT32_MINPOS, FLOAT32_SAFE_MAX);
} else {
new_min = new_min.clamp(FLOAT32_SAFE_MIN, FLOAT32_SAFE_MAX);
new_max = new_max.clamp(FLOAT32_SAFE_MIN, FLOAT32_SAFE_MAX);
}
(new_min, new_max)
}
pub fn zoom_about(
limits: Limits,
factor: f64,
cx: f64,
cy: f64,
x_scale: Scale,
y_scale: Scale,
) -> Limits {
let (x_min, x_max, y_min, y_max) = limits;
let silx_scale = 1.0 / factor;
let (new_x_min, new_x_max) =
scale1d_range(x_min, x_max, cx, silx_scale, x_scale == Scale::Log10);
let (new_y_min, new_y_max) =
scale1d_range(y_min, y_max, cy, silx_scale, y_scale == Scale::Log10);
(new_x_min, new_x_max, new_y_min, new_y_max)
}
pub type ViewLimits = (Limits, Option<(f64, f64)>);
pub fn pan_view(
view: ViewLimits,
area: Rect,
delta_px: Vec2,
x_scale: Scale,
y_scale: Scale,
) -> ViewLimits {
let (limits, y2) = view;
let next = pan(limits, area, delta_px, x_scale, y_scale);
let h = area.height().max(1.0) as f64;
let next_y2 = y2.map(|(lo, hi)| pan_axis(lo, hi, -(delta_px.y as f64), h, y_scale));
(next, next_y2)
}
pub fn zoom_view_about(
view: ViewLimits,
factor: f64,
centre: (f64, f64),
cy2: Option<f64>,
x_scale: Scale,
y_scale: Scale,
enabled: (bool, bool),
) -> ViewLimits {
let ((x_min, x_max, y_min, y_max), y2) = view;
let (cx, cy) = centre;
let (x_enabled, y_enabled) = enabled;
let silx_scale = 1.0 / factor;
let (nx0, nx1) = if x_enabled {
scale1d_range(x_min, x_max, cx, silx_scale, x_scale == Scale::Log10)
} else {
(x_min, x_max)
};
let (ny0, ny1) = if y_enabled {
scale1d_range(y_min, y_max, cy, silx_scale, y_scale == Scale::Log10)
} else {
(y_min, y_max)
};
let next_y2 = match (y2, cy2) {
(Some((lo, hi)), Some(c)) if y_enabled => Some(scale1d_range(
lo,
hi,
c,
silx_scale,
y_scale == Scale::Log10,
)),
(y2, _) => y2,
};
((nx0, nx1, ny0, ny1), next_y2)
}
pub fn apply_pan(min: f64, max: f64, pan_factor: f64, is_log10: bool) -> (f64, f64) {
if is_log10 && min > 0.0 {
let log_min = min.log10();
let log_max = max.log10();
let log_offset = pan_factor * (log_max - log_min);
let new_min = 10f64.powf(log_min + log_offset);
let new_max = 10f64.powf(log_max + log_offset);
if new_min > 0.0 && new_max.is_finite() {
(new_min, new_max)
} else {
(min, max)
}
} else {
let offset = pan_factor * (max - min);
let new_min = min + offset;
let new_max = max + offset;
if new_min > f64::NEG_INFINITY && new_max < f64::INFINITY {
(new_min, new_max)
} else {
(min, max)
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum PanDirection {
Up,
Down,
Left,
Right,
}
pub fn box_zoom(ax: f64, ay: f64, bx: f64, by: f64) -> Limits {
(ax.min(bx), ax.max(bx), ay.min(by), ay.max(by))
}
pub fn constrain_zoom_axes(
zoomed: Limits,
current: Limits,
x_enabled: bool,
y_enabled: bool,
) -> Limits {
let (zx0, zx1, zy0, zy1) = zoomed;
let (cx0, cx1, cy0, cy1) = current;
let (x0, x1) = if x_enabled { (zx0, zx1) } else { (cx0, cx1) };
let (y0, y1) = if y_enabled { (zy0, zy1) } else { (cy0, cy1) };
(x0, x1, y0, y1)
}
const WHEEL_NOTCH_POINTS: f64 = 40.0;
pub fn wheel_zoom_factor(scroll_y: f32) -> f64 {
(-(scroll_y as f64) * (1.1f64.ln() / WHEEL_NOTCH_POINTS)).exp()
}
pub fn is_valid(limits: Limits) -> bool {
let (x_min, x_max, y_min, y_max) = limits;
x_max > x_min && y_max > y_min
}
pub fn clamp_limits(limits: Limits, x_log: bool, y_log: bool) -> Limits {
let (x_min, x_max, y_min, y_max) = limits;
let (nx0, nx1) = clamp_axis_limits(x_min, x_max, x_log);
let (ny0, ny1) = clamp_axis_limits(y_min, y_max, y_log);
(nx0, nx1, ny0, ny1)
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum DrawMode {
Rectangle,
Ellipse,
Line,
HLine,
VLine,
Polygon,
FreeHand,
Point,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct DrawInput {
pub data: (f64, f64),
pub pixel: (f32, f32),
}
impl DrawInput {
pub fn from_pixel(transform: &Transform, pixel: Pos2) -> Self {
Self {
data: transform.pixel_to_data(pixel),
pixel: (pixel.x, pixel.y),
}
}
}
#[derive(Clone, Debug, PartialEq)]
pub enum DrawParams {
Rectangle {
x: f64,
y: f64,
width: f64,
height: f64,
},
Ellipse {
center: (f64, f64),
semi_axes: (f64, f64),
},
Line { start: (f64, f64), end: (f64, f64) },
HLine { y: f64 },
VLine { x: f64 },
Polygon { vertices: Vec<(f64, f64)> },
FreeHand { vertices: Vec<(f64, f64)> },
Point { x: f64, y: f64 },
}
#[derive(Clone, Debug, PartialEq)]
pub enum DrawEvent {
InProgress {
mode: DrawMode,
points: Vec<(f64, f64)>,
},
Finished { mode: DrawMode, params: DrawParams },
}
pub const DRAW_CLOSE_THRESHOLD_PX: f32 = 4.0;
const ELLIPSE_PREVIEW_POINTS: usize = 27;
#[derive(Clone, Debug, PartialEq)]
enum Phase {
Idle,
TwoPoint { start: DrawInput },
OnePoint,
Polygon {
first: DrawInput,
points: Vec<DrawInput>,
},
FreeHand { points: Vec<(f64, f64)> },
}
#[derive(Clone, Debug)]
pub struct DrawState {
mode: DrawMode,
phase: Phase,
close_threshold_px: f32,
}
impl DrawState {
pub fn new(mode: DrawMode) -> Self {
Self {
mode,
phase: Phase::Idle,
close_threshold_px: DRAW_CLOSE_THRESHOLD_PX,
}
}
pub fn with_close_threshold(mut self, px: f32) -> Self {
self.close_threshold_px = px;
self
}
pub fn close_threshold_px(&self) -> f32 {
self.close_threshold_px
}
pub fn mode(&self) -> DrawMode {
self.mode
}
pub fn is_active(&self) -> bool {
!matches!(self.phase, Phase::Idle)
}
pub fn preview(&self) -> Option<Vec<(f64, f64)>> {
match &self.phase {
Phase::Idle => None,
Phase::TwoPoint { .. } | Phase::OnePoint => None,
Phase::Polygon { points, .. } => Some(points.iter().map(|p| p.data).collect()),
Phase::FreeHand { points } => Some(points.clone()),
}
}
pub fn on_press(&mut self, input: DrawInput) -> Option<DrawEvent> {
match self.mode {
DrawMode::Rectangle | DrawMode::Ellipse | DrawMode::Line => {
self.phase = Phase::TwoPoint { start: input };
None
}
DrawMode::HLine | DrawMode::VLine => {
self.phase = Phase::OnePoint;
Some(self.one_point_progress(input))
}
DrawMode::Polygon => {
if matches!(self.phase, Phase::Idle) {
self.phase = Phase::Polygon {
first: input,
points: vec![input, input],
};
Some(self.polygon_progress())
} else {
None
}
}
DrawMode::FreeHand => {
self.phase = Phase::FreeHand {
points: vec![input.data],
};
Some(self.freehand_progress())
}
DrawMode::Point => {
Some(DrawEvent::Finished {
mode: DrawMode::Point,
params: DrawParams::Point {
x: input.data.0,
y: input.data.1,
},
})
}
}
}
pub fn on_move(&mut self, input: DrawInput) -> Option<DrawEvent> {
match self.mode {
DrawMode::Rectangle | DrawMode::Ellipse | DrawMode::Line => match &self.phase {
Phase::TwoPoint { start } => Some(self.two_point_progress(*start, input)),
_ => None,
},
DrawMode::HLine | DrawMode::VLine => match self.phase {
Phase::OnePoint => Some(self.one_point_progress(input)),
_ => None,
},
DrawMode::Polygon => {
if let Phase::Polygon { first, points } = &mut self.phase {
let snapped = if Self::within_threshold(
first.pixel,
input.pixel,
self.close_threshold_px,
) {
*first
} else {
input
};
if let Some(last) = points.last_mut() {
*last = snapped;
}
Some(self.polygon_progress())
} else {
None
}
}
DrawMode::FreeHand => {
if let Phase::FreeHand { points } = &mut self.phase {
if points.last() != Some(&input.data) {
points.push(input.data);
}
Some(self.freehand_progress())
} else {
None
}
}
DrawMode::Point => None,
}
}
pub fn on_release(&mut self, input: DrawInput) -> Option<DrawEvent> {
match self.mode {
DrawMode::Rectangle | DrawMode::Ellipse | DrawMode::Line => {
match std::mem::replace(&mut self.phase, Phase::Idle) {
Phase::TwoPoint { start } => Some(self.two_point_finished(start, input)),
other => {
self.phase = other;
None
}
}
}
DrawMode::HLine | DrawMode::VLine => {
if matches!(self.phase, Phase::OnePoint) {
self.phase = Phase::Idle;
Some(self.one_point_finished(input))
} else {
None
}
}
DrawMode::Polygon => self.polygon_on_release(input),
DrawMode::FreeHand => {
if let Phase::FreeHand { points } = &mut self.phase {
if points.last() != Some(&input.data) {
points.push(input.data);
}
let vertices = std::mem::take(points);
self.phase = Phase::Idle;
Some(DrawEvent::Finished {
mode: DrawMode::FreeHand,
params: DrawParams::FreeHand { vertices },
})
} else {
None
}
}
DrawMode::Point => None,
}
}
pub fn cancel(&mut self) {
self.phase = Phase::Idle;
}
pub fn validate(&mut self) -> Option<DrawEvent> {
if self.mode != DrawMode::Polygon {
return None;
}
match &self.phase {
Phase::Polygon { points, .. } if points.len() > 2 => Some(self.close_polygon()),
_ => None,
}
}
fn within_threshold(a: (f32, f32), b: (f32, f32), threshold: f32) -> bool {
(a.0 - b.0).abs() <= threshold && (a.1 - b.1).abs() <= threshold
}
fn two_point_progress(&self, start: DrawInput, cur: DrawInput) -> DrawEvent {
DrawEvent::InProgress {
mode: self.mode,
points: self.two_point_preview(start.data, cur.data),
}
}
fn two_point_finished(&self, start: DrawInput, end: DrawInput) -> DrawEvent {
let params = match self.mode {
DrawMode::Rectangle => {
let (sx, sy) = start.data;
let (ex, ey) = end.data;
let x = sx.min(ex);
let y = sy.min(ey);
DrawParams::Rectangle {
x,
y,
width: sx.max(ex) - x,
height: sy.max(ey) - y,
}
}
DrawMode::Ellipse => {
let semi_axes = ellipse_semi_axes(start.data, end.data);
DrawParams::Ellipse {
center: start.data,
semi_axes,
}
}
DrawMode::Line => DrawParams::Line {
start: start.data,
end: end.data,
},
_ => unreachable!("two_point_finished only for rectangle/ellipse/line"),
};
DrawEvent::Finished {
mode: self.mode,
params,
}
}
fn two_point_preview(&self, start: (f64, f64), cur: (f64, f64)) -> Vec<(f64, f64)> {
match self.mode {
DrawMode::Rectangle => {
vec![start, (start.0, cur.1), cur, (cur.0, start.1)]
}
DrawMode::Line => vec![start, cur],
DrawMode::Ellipse => {
let (a, b) = ellipse_semi_axes(start, cur);
ellipse_preview(start, a, b)
}
_ => unreachable!("two_point_preview only for rectangle/ellipse/line"),
}
}
fn one_point_progress(&self, input: DrawInput) -> DrawEvent {
DrawEvent::InProgress {
mode: self.mode,
points: vec![input.data],
}
}
fn one_point_finished(&self, input: DrawInput) -> DrawEvent {
let params = match self.mode {
DrawMode::HLine => DrawParams::HLine { y: input.data.1 },
DrawMode::VLine => DrawParams::VLine { x: input.data.0 },
_ => unreachable!("one_point_finished only for hline/vline"),
};
DrawEvent::Finished {
mode: self.mode,
params,
}
}
fn polygon_progress(&self) -> DrawEvent {
let points = match &self.phase {
Phase::Polygon { points, .. } => points.iter().map(|p| p.data).collect(),
_ => Vec::new(),
};
DrawEvent::InProgress {
mode: DrawMode::Polygon,
points,
}
}
fn polygon_on_release(&mut self, input: DrawInput) -> Option<DrawEvent> {
let Phase::Polygon { first, points } = &mut self.phase else {
return None;
};
let close = points.len() > 2
&& Self::within_threshold(first.pixel, input.pixel, self.close_threshold_px);
if close {
return Some(self.close_polygon());
}
let prev = points.get(points.len().wrapping_sub(2)).map(|p| p.pixel);
let near_prev = prev
.map(|pp| Self::within_threshold(pp, input.pixel, self.close_threshold_px))
.unwrap_or(false);
if let Some(last) = points.last_mut() {
*last = input;
}
if !near_prev {
points.push(input);
}
Some(self.polygon_progress())
}
fn close_polygon(&mut self) -> DrawEvent {
let vertices = match &mut self.phase {
Phase::Polygon { points, .. } => {
let mut v: Vec<(f64, f64)> = points.iter().map(|p| p.data).collect();
v.pop();
v
}
_ => Vec::new(),
};
self.phase = Phase::Idle;
DrawEvent::Finished {
mode: DrawMode::Polygon,
params: DrawParams::Polygon { vertices },
}
}
fn freehand_progress(&self) -> DrawEvent {
let points = match &self.phase {
Phase::FreeHand { points } => points.clone(),
_ => Vec::new(),
};
DrawEvent::InProgress {
mode: DrawMode::FreeHand,
points,
}
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum FillMode {
#[default]
Hatch,
Solid,
None,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct SelectionStyle {
pub fill: FillMode,
pub color: egui::Color32,
}
impl Default for SelectionStyle {
fn default() -> Self {
Self {
fill: FillMode::Hatch,
color: egui::Color32::from_rgba_unmultiplied(0, 0, 0, 128),
}
}
}
impl SelectionStyle {
pub fn new(fill: FillMode, color: egui::Color32) -> Self {
Self { fill, color }
}
}
pub fn hatch_lines(rect: Rect, spacing: f32) -> Vec<(Pos2, Pos2)> {
if spacing <= 0.0 || rect.width() <= 0.0 || rect.height() <= 0.0 {
return Vec::new();
}
let mut lines = Vec::new();
let (left, right, top, bottom) = (rect.left(), rect.right(), rect.top(), rect.bottom());
let c_min = left - bottom;
let c_max = right - top;
let mut c = (c_min / spacing).floor() * spacing;
while c <= c_max {
let mut pts: Vec<Pos2> = Vec::new();
let xt = top + c;
if xt >= left && xt <= right {
pts.push(egui::pos2(xt, top));
}
let xb = bottom + c;
if xb >= left && xb <= right {
pts.push(egui::pos2(xb, bottom));
}
let yl = left - c;
if yl >= top && yl <= bottom {
pts.push(egui::pos2(left, yl));
}
let yr = right - c;
if yr >= top && yr <= bottom {
pts.push(egui::pos2(right, yr));
}
if pts.len() >= 2 {
lines.push((pts[0], pts[1]));
}
c += spacing;
}
lines
}
pub fn ellipse_semi_axes(center: (f64, f64), point: (f64, f64)) -> (f64, f64) {
let mut x = (center.0 - point.0).abs();
let mut y = (center.1 - point.1).abs();
if x == 0.0 || y == 0.0 {
return (x, y);
}
let swap = x < y;
if swap {
std::mem::swap(&mut x, &mut y);
}
let e = (x * x - y * y).sqrt() / x;
let a = (x * x + y * y / (1.0 - e * e)).sqrt();
let b = a * (1.0 - e * e).sqrt();
if swap { (b, a) } else { (a, b) }
}
fn ellipse_preview(center: (f64, f64), a: f64, b: f64) -> Vec<(f64, f64)> {
let n = ELLIPSE_PREVIEW_POINTS;
(0..n)
.map(|i| {
let angle = i as f64 * std::f64::consts::TAU / n as f64;
(center.0 + angle.cos() * a, center.1 + angle.sin() * b)
})
.collect()
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum CursorShape {
SizeHor,
SizeVer,
SizeNwse,
SizeNesw,
SizeAll,
#[default]
Default,
}
impl CursorShape {
pub fn to_egui(self) -> egui::CursorIcon {
match self {
CursorShape::SizeHor => egui::CursorIcon::ResizeHorizontal,
CursorShape::SizeVer => egui::CursorIcon::ResizeVertical,
CursorShape::SizeNwse => egui::CursorIcon::ResizeNwSe,
CursorShape::SizeNesw => egui::CursorIcon::ResizeNeSw,
CursorShape::SizeAll => egui::CursorIcon::Move,
CursorShape::Default => egui::CursorIcon::Default,
}
}
}
pub fn cursor_for_edge(edge: RoiEdge, t: &Transform) -> CursorShape {
let flip = t.x.inverted ^ t.y.inverted;
match edge {
RoiEdge::Left | RoiEdge::Right => CursorShape::SizeHor,
RoiEdge::Top | RoiEdge::Bottom => CursorShape::SizeVer,
RoiEdge::TopLeft | RoiEdge::BottomRight => {
if flip {
CursorShape::SizeNesw
} else {
CursorShape::SizeNwse
}
}
RoiEdge::TopRight | RoiEdge::BottomLeft => {
if flip {
CursorShape::SizeNwse
} else {
CursorShape::SizeNesw
}
}
RoiEdge::Vertex(_) => CursorShape::SizeAll,
}
}
pub fn cursor_for_grab(edge: Option<RoiEdge>, t: &Transform) -> CursorShape {
edge.map(|e| cursor_for_edge(e, t)).unwrap_or_default()
}
pub fn marker_cursor(marker: &Marker) -> CursorShape {
match marker.kind {
MarkerKind::VLine { .. } => CursorShape::SizeHor,
MarkerKind::HLine { .. } => CursorShape::SizeVer,
MarkerKind::Point { .. } => match marker.constraint {
MarkerConstraint::None => CursorShape::SizeAll,
MarkerConstraint::Horizontal => CursorShape::SizeVer,
MarkerConstraint::Vertical => CursorShape::SizeHor,
},
}
}
pub fn marker_at(markers: &[Marker], transform: &Transform, cursor: Pos2) -> Option<usize> {
markers
.iter()
.enumerate()
.rev()
.find(|(_, m)| m.is_draggable && m.pick(transform, cursor))
.map(|(i, _)| i)
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum RoiDrawKind {
Rect,
HRange,
VRange,
HLine,
VLine,
Point,
Line,
Polygon,
Cross,
Circle,
Ellipse,
Arc,
Band,
}
impl RoiDrawKind {
pub fn short_name(self) -> &'static str {
match self {
RoiDrawKind::Rect => "rectangle",
RoiDrawKind::HRange => "hrange",
RoiDrawKind::VRange => "vrange",
RoiDrawKind::HLine => "hline",
RoiDrawKind::VLine => "vline",
RoiDrawKind::Point => "point",
RoiDrawKind::Line => "line",
RoiDrawKind::Polygon => "polygon",
RoiDrawKind::Cross => "cross",
RoiDrawKind::Circle => "circle",
RoiDrawKind::Ellipse => "ellipse",
RoiDrawKind::Arc => "arc",
RoiDrawKind::Band => "band",
}
}
}
pub fn roi_draw_mode(kind: RoiDrawKind) -> DrawMode {
match kind {
RoiDrawKind::Rect => DrawMode::Rectangle,
RoiDrawKind::Ellipse => DrawMode::Ellipse,
RoiDrawKind::Polygon => DrawMode::Polygon,
RoiDrawKind::Point | RoiDrawKind::Cross => DrawMode::Point,
RoiDrawKind::HLine => DrawMode::HLine,
RoiDrawKind::VLine => DrawMode::VLine,
RoiDrawKind::Line
| RoiDrawKind::Circle
| RoiDrawKind::HRange
| RoiDrawKind::VRange
| RoiDrawKind::Arc
| RoiDrawKind::Band => DrawMode::Line,
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum RoiKeyAction {
Validate,
UndoLast,
}
pub fn roi_key_action(key: egui::Key, command: bool) -> Option<RoiKeyAction> {
match key {
egui::Key::Enter => Some(RoiKeyAction::Validate),
egui::Key::Delete | egui::Key::Backspace => Some(RoiKeyAction::UndoLast),
egui::Key::Z if command => Some(RoiKeyAction::UndoLast),
_ => None,
}
}
pub fn roi_from_draw(kind: RoiDrawKind, params: &DrawParams) -> Option<Roi> {
match (kind, params) {
(
RoiDrawKind::Rect,
DrawParams::Rectangle {
x,
y,
width,
height,
},
) => Some(Roi::Rect {
x: (*x, x + width),
y: (*y, y + height),
}),
(RoiDrawKind::Line, DrawParams::Line { start, end }) => Some(Roi::Line {
start: *start,
end: *end,
}),
(RoiDrawKind::Polygon, DrawParams::Polygon { vertices }) => Some(Roi::Polygon {
vertices: vertices.clone(),
}),
(RoiDrawKind::Point, DrawParams::Point { x, y }) => Some(Roi::Point { x: *x, y: *y }),
(RoiDrawKind::Cross, DrawParams::Point { x, y }) => Some(Roi::Cross { center: (*x, *y) }),
(RoiDrawKind::Ellipse, DrawParams::Ellipse { center, semi_axes }) => Some(Roi::Ellipse {
center: *center,
radii: *semi_axes,
orientation: 0.0,
}),
(RoiDrawKind::Circle, DrawParams::Line { start, end }) => {
let r = (end.0 - start.0).hypot(end.1 - start.1);
Some(Roi::Circle {
center: *start,
radius: r,
})
}
(RoiDrawKind::HRange, DrawParams::Line { start, end }) => Some(Roi::HRange {
y: (start.1.min(end.1), start.1.max(end.1)),
}),
(RoiDrawKind::VRange, DrawParams::Line { start, end }) => Some(Roi::VRange {
x: (start.0.min(end.0), start.0.max(end.0)),
}),
(RoiDrawKind::HLine, DrawParams::HLine { y }) => Some(Roi::HLine { y: *y }),
(RoiDrawKind::VLine, DrawParams::VLine { x }) => Some(Roi::VLine { x: *x }),
(RoiDrawKind::Arc, DrawParams::Line { start, end }) => {
Some(arc_from_two_points(*start, *end))
}
(RoiDrawKind::Band, DrawParams::Line { start, end }) => {
let width = 0.1 * (end.0 - start.0).hypot(end.1 - start.1);
Some(Roi::Band {
begin: *start,
end: *end,
width: width.max(0.0),
})
}
_ => None,
}
}
pub fn arc_from_two_points(point0: (f64, f64), point1: (f64, f64)) -> Roi {
let mid_center = ((point0.0 + point1.0) * 0.5, (point0.1 + point1.1) * 0.5);
let normal_raw = (point1.0 - mid_center.0, point1.1 - mid_center.1);
let normal = (normal_raw.1, -normal_raw.0);
let default_curvature = std::f64::consts::PI / 5.0;
let weight_coef = 0.20;
let mid = (
mid_center.0 - normal.0 * default_curvature,
mid_center.1 - normal.1 * default_curvature,
);
let weight = (point0.0 - point1.0).hypot(point0.1 - point1.1) * weight_coef;
arc_from_three_points(point0, mid, point1, weight)
}
pub fn arc_from_three_points(
start: (f64, f64),
mid: (f64, f64),
end: (f64, f64),
weight: f64,
) -> Roi {
let two_pi = std::f64::consts::TAU;
let angle = |p: (f64, f64), c: (f64, f64)| (p.1 - c.1).atan2(p.0 - c.0);
let close = |a: f64, b: f64| (a - b).abs() <= 1e-8 + 1e-5 * b.abs();
if close(start.0, end.0) && close(start.1, end.1) {
let center = ((start.0 + mid.0) * 0.5, (start.1 + mid.1) * 0.5);
let radius = (start.0 - center.0).hypot(start.1 - center.1);
let start_angle = angle(start, center);
return Roi::Arc {
center,
radius,
weight,
start_angle,
end_angle: start_angle + two_pi,
};
}
let cross = (mid.0 - start.0) * (end.1 - start.1) - (mid.1 - start.1) * (end.0 - start.0);
if cross.abs() < 1e-5 {
return Roi::Arc {
center: ((start.0 + end.0) * 0.5, (start.1 + end.1) * 0.5),
radius: 0.0,
weight: 0.0,
start_angle: 0.0,
end_angle: 0.0,
};
}
let (center, radius) = circle_through(start, mid, end);
let start_angle = angle(start, center);
let mid_angle = angle(mid, center);
let mut end_angle = angle(end, center);
let relative_mid = (end_angle - mid_angle + two_pi).rem_euclid(two_pi);
let relative_end = (end_angle - start_angle + two_pi).rem_euclid(two_pi);
if relative_mid < relative_end {
if end_angle < start_angle {
end_angle += two_pi;
}
} else if end_angle > start_angle {
end_angle -= two_pi;
}
Roi::Arc {
center,
radius,
weight,
start_angle,
end_angle,
}
}
pub type ArcControlPoints = ((f64, f64), (f64, f64), (f64, f64));
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ArcControlPoint {
Start,
Mid,
End,
}
pub fn arc_control_points(arc: &Roi) -> Option<ArcControlPoints> {
let Roi::Arc {
center,
radius,
start_angle,
end_angle,
..
} = arc
else {
return None;
};
let (cx, cy) = *center;
let at = |a: f64| (cx + radius * a.cos(), cy + radius * a.sin());
let mid_angle = (*start_angle + *end_angle) * 0.5;
Some((at(*start_angle), at(mid_angle), at(*end_angle)))
}
pub fn arc_three_point_drag(arc: &Roi, point: ArcControlPoint, to: (f64, f64)) -> Roi {
let (Some((start, mid, end)), Roi::Arc { weight, .. }) = (arc_control_points(arc), arc) else {
return arc.clone();
};
let weight = *weight;
let (start, mid, end) = match point {
ArcControlPoint::Start => (to, mid, end),
ArcControlPoint::Mid => (start, to, end),
ArcControlPoint::End => (start, mid, to),
};
arc_from_three_points(start, mid, end, weight)
}
fn arc_control_point_for(index: usize) -> ArcControlPoint {
match index {
0 => ArcControlPoint::Start,
1 => ArcControlPoint::Mid,
_ => ArcControlPoint::End,
}
}
#[must_use]
pub fn arc_is_three_point(managed: &ManagedRoi) -> bool {
matches!(managed.roi, Roi::Arc { .. })
&& managed.interaction_mode() == Some(RoiInteractionMode::ArcThreePoint)
}
#[must_use]
pub fn arc_three_point_handles(arc: &Roi) -> Option<[RoiHandle; 3]> {
let (start, mid, end) = arc_control_points(arc)?;
let h = |p: (f64, f64)| RoiHandle {
pos: [p.0, p.1],
kind: HandleKind::Vertex,
};
Some([h(start), h(mid), h(end)])
}
#[must_use]
pub fn arc_three_point_grab(
arc: &Roi,
transform: &Transform,
cursor: Pos2,
grab_px: f32,
) -> Option<RoiEdge> {
let handles = arc_three_point_handles(arc)?;
let mut best: Option<(usize, f32)> = None;
for (i, handle) in handles.iter().enumerate() {
let p = transform.data_to_pixel(handle.pos[0], handle.pos[1]);
let dist = cursor.distance(p);
if dist <= grab_px && best.is_none_or(|(_, d)| dist < d) {
best = Some((i, dist));
}
}
best.map(|(i, _)| RoiEdge::Vertex(i))
}
pub fn roi_apply_edge_drag(managed: &mut ManagedRoi, edge: RoiEdge, to: (f64, f64)) {
if arc_is_three_point(managed) {
if let RoiEdge::Vertex(i) = edge {
managed.roi = arc_three_point_drag(&managed.roi, arc_control_point_for(i), to);
}
} else {
managed.roi.move_edge(edge, to);
}
}
fn circle_through(pt1: (f64, f64), pt2: (f64, f64), pt3: (f64, f64)) -> ((f64, f64), f64) {
let sub = |a: (f64, f64), b: (f64, f64)| (a.0 - b.0, a.1 - b.1);
let mul = |a: (f64, f64), b: (f64, f64)| (a.0 * b.0 - a.1 * b.1, a.0 * b.1 + a.1 * b.0);
let div = |a: (f64, f64), b: (f64, f64)| {
let den = b.0 * b.0 + b.1 * b.1;
((a.0 * b.0 + a.1 * b.1) / den, (a.1 * b.0 - a.0 * b.1) / den)
};
let (x, y, z) = (pt1, pt2, pt3);
let w = div(sub(z, x), sub(y, x));
let w_abs2 = w.0 * w.0 + w.1 * w.1;
let num = mul(sub(x, y), (w.0 - w_abs2, w.1));
let den = (0.0, 2.0 * w.1);
let c = sub(div(num, den), x);
let center = (-c.0, -c.1);
let cx = (c.0 + x.0, c.1 + x.1);
let radius = (cx.0 * cx.0 + cx.1 * cx.1).sqrt();
(center, radius)
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum RoiGrab {
Edge(RoiEdge),
Translate,
}
pub fn roi_grab_at(
rois: &[ManagedRoi],
transform: &Transform,
cursor: Pos2,
grab_px: f32,
) -> Option<(usize, RoiGrab)> {
let data = transform.pixel_to_data(cursor);
for (i, managed) in rois.iter().enumerate().rev() {
if !managed.visible {
continue;
}
let roi = &managed.roi;
let edge = if arc_is_three_point(managed) {
arc_three_point_grab(roi, transform, cursor, grab_px)
} else {
roi.edge_at(transform, cursor, grab_px)
};
if let Some(edge) = edge {
return Some((i, RoiGrab::Edge(edge)));
}
if roi.contains(data) {
return Some((i, RoiGrab::Translate));
}
}
None
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum MouseButton {
Left,
Middle,
Right,
}
impl MouseButton {
pub fn from_egui(button: egui::PointerButton) -> Self {
match button {
egui::PointerButton::Primary => MouseButton::Left,
egui::PointerButton::Middle => MouseButton::Middle,
_ => MouseButton::Right,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum PlotPointerEvent {
Clicked {
button: MouseButton,
data: (f64, f64),
pixel: (f32, f32),
},
DoubleClicked {
button: MouseButton,
data: (f64, f64),
pixel: (f32, f32),
},
Moved {
button: Option<MouseButton>,
data: (f64, f64),
pixel: (f32, f32),
},
LimitsChanged {
x: (f64, f64),
y: (f64, f64),
y2: Option<(f64, f64)>,
},
}
impl PlotPointerEvent {
pub fn clicked(button: MouseButton, transform: &Transform, pixel: Pos2) -> Self {
PlotPointerEvent::Clicked {
button,
data: transform.pixel_to_data(pixel),
pixel: (pixel.x, pixel.y),
}
}
pub fn double_clicked(button: MouseButton, transform: &Transform, pixel: Pos2) -> Self {
PlotPointerEvent::DoubleClicked {
button,
data: transform.pixel_to_data(pixel),
pixel: (pixel.x, pixel.y),
}
}
pub fn moved(button: Option<MouseButton>, transform: &Transform, pixel: Pos2) -> Self {
PlotPointerEvent::Moved {
button,
data: transform.pixel_to_data(pixel),
pixel: (pixel.x, pixel.y),
}
}
pub fn limits_changed(x: (f64, f64), y: (f64, f64), y2: Option<(f64, f64)>) -> Self {
PlotPointerEvent::LimitsChanged { x, y, y2 }
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct PointPick {
pub index: usize,
pub x: f64,
pub y: f64,
pub dist_px: f32,
}
pub fn nearest_point(
points: &[(f64, f64)],
transform: &Transform,
cursor: Pos2,
threshold_px: f32,
) -> Option<PointPick> {
let mut best: Option<PointPick> = None;
for (index, &(x, y)) in points.iter().enumerate() {
let dist_px = transform.data_to_pixel(x, y).distance(cursor);
if dist_px <= threshold_px && best.is_none_or(|b| dist_px < b.dist_px) {
best = Some(PointPick {
index,
x,
y,
dist_px,
});
}
}
best
}
pub fn image_index(
transform: &Transform,
origin: (f64, f64),
scale: (f64, f64),
dims: (u32, u32),
cursor: Pos2,
) -> Option<(u32, u32)> {
if scale.0 <= 0.0 || scale.1 <= 0.0 {
return None;
}
let (x, y) = transform.pixel_to_data(cursor);
if !x.is_finite() || !y.is_finite() {
return None;
}
let col = ((x - origin.0) / scale.0).floor();
let row = ((y - origin.1) / scale.1).floor();
if col < 0.0 || row < 0.0 {
return None;
}
let (col, row) = (col as u32, row as u32);
(col < dims.0 && row < dims.1).then_some((col, row))
}
#[cfg(test)]
mod tests {
use super::*;
use egui::{pos2, vec2};
fn area_100() -> Rect {
Rect::from_min_size(pos2(0.0, 0.0), vec2(100.0, 100.0))
}
fn close(a: Limits, b: Limits) -> bool {
let t = 1e-9;
(a.0 - b.0).abs() <= t
&& (a.1 - b.1).abs() <= t
&& (a.2 - b.2).abs() <= t
&& (a.3 - b.3).abs() <= t
}
#[test]
fn roi_draw_kind_short_name_matches_silx() {
assert_eq!(RoiDrawKind::Rect.short_name(), "rectangle");
assert_eq!(RoiDrawKind::HRange.short_name(), "hrange");
assert_eq!(RoiDrawKind::VRange.short_name(), "vrange");
assert_eq!(RoiDrawKind::HLine.short_name(), "hline");
assert_eq!(RoiDrawKind::VLine.short_name(), "vline");
assert_eq!(RoiDrawKind::Point.short_name(), "point");
assert_eq!(RoiDrawKind::Line.short_name(), "line");
assert_eq!(RoiDrawKind::Polygon.short_name(), "polygon");
assert_eq!(RoiDrawKind::Cross.short_name(), "cross");
assert_eq!(RoiDrawKind::Circle.short_name(), "circle");
assert_eq!(RoiDrawKind::Ellipse.short_name(), "ellipse");
assert_eq!(RoiDrawKind::Arc.short_name(), "arc");
assert_eq!(RoiDrawKind::Band.short_name(), "band");
}
#[test]
fn pan_right_shifts_view_left() {
let out = pan(
(0.0, 10.0, 0.0, 10.0),
area_100(),
vec2(10.0, 0.0),
Scale::Linear,
Scale::Linear,
);
assert!(close(out, (-1.0, 9.0, 0.0, 10.0)), "{out:?}");
}
#[test]
fn pan_down_increases_y_limits() {
let out = pan(
(0.0, 10.0, 0.0, 10.0),
area_100(),
vec2(0.0, 10.0),
Scale::Linear,
Scale::Linear,
);
assert!(close(out, (0.0, 10.0, 1.0, 11.0)), "{out:?}");
}
#[test]
fn pan_log_round_trips_in_log_space() {
let limits = (1.0, 100.0, 1.0, 100.0);
let area = area_100();
let forward = pan(limits, area, vec2(20.0, 13.0), Scale::Log10, Scale::Log10);
let back = pan(
forward,
area,
vec2(-20.0, -13.0),
Scale::Log10,
Scale::Log10,
);
assert!(close(back, limits), "{back:?}");
assert!(!close(forward, limits), "{forward:?}");
}
#[test]
fn pan_log_translates_in_log_space() {
let out = pan(
(1.0, 100.0, 1.0, 100.0),
area_100(),
vec2(50.0, 0.0),
Scale::Log10,
Scale::Linear,
);
assert!((out.0 - 0.1).abs() <= 1e-9, "{out:?}");
assert!((out.1 - 10.0).abs() <= 1e-9, "{out:?}");
assert!(
(out.2 - 1.0).abs() <= 1e-9 && (out.3 - 100.0).abs() <= 1e-9,
"{out:?}"
);
}
#[test]
fn zoom_about_center_halves_span_keeping_center() {
let out = zoom_about(
(0.0, 10.0, 0.0, 10.0),
0.5,
5.0,
5.0,
Scale::Linear,
Scale::Linear,
);
assert!(close(out, (2.5, 7.5, 2.5, 7.5)), "{out:?}");
}
#[test]
fn zoom_about_keeps_anchor_fixed() {
let limits = (0.0, 10.0, 0.0, 10.0);
let (cx, cy) = (8.0, 2.0);
let out = zoom_about(limits, 0.3, cx, cy, Scale::Linear, Scale::Linear);
let frac_before = (cx - limits.0) / (limits.1 - limits.0);
let frac_after = (cx - out.0) / (out.1 - out.0);
assert!((frac_before - frac_after).abs() <= 1e-9);
let _ = cy;
}
#[test]
fn zoom_about_log_keeps_anchor_data_coord_fixed() {
let limits = (1.0, 1000.0, 1.0, 1000.0);
let (cx, cy) = (10.0, 100.0);
let out = zoom_about(limits, 0.5, cx, cy, Scale::Log10, Scale::Log10);
let frac_log =
|v: f64, lo: f64, hi: f64| (v.log10() - lo.log10()) / (hi.log10() - lo.log10());
let fx_before = frac_log(cx, limits.0, limits.1);
let fx_after = frac_log(cx, out.0, out.1);
assert!(
(fx_before - fx_after).abs() <= 1e-9,
"x {fx_before} {fx_after}"
);
let fy_before = frac_log(cy, limits.2, limits.3);
let fy_after = frac_log(cy, out.2, out.3);
assert!(
(fy_before - fy_after).abs() <= 1e-9,
"y {fy_before} {fy_after}"
);
}
#[test]
fn pan_view_shifts_y2_in_the_same_gesture() {
let (out, y2) = pan_view(
((0.0, 10.0, 0.0, 10.0), Some((0.0, 100.0))),
area_100(),
vec2(0.0, 10.0),
Scale::Linear,
Scale::Linear,
);
assert!(close(out, (0.0, 10.0, 1.0, 11.0)), "{out:?}");
let (lo, hi) = y2.expect("y2 present");
assert!(
(lo - 10.0).abs() <= 1e-9 && (hi - 110.0).abs() <= 1e-9,
"{lo} {hi}"
);
}
#[test]
fn pan_view_without_y2_axis_stays_none() {
let (out, y2) = pan_view(
((0.0, 10.0, 0.0, 10.0), None),
area_100(),
vec2(10.0, 0.0),
Scale::Linear,
Scale::Linear,
);
assert!(close(out, (-1.0, 9.0, 0.0, 10.0)), "{out:?}");
assert_eq!(y2, None);
}
#[test]
fn zoom_view_about_scales_y2_about_right_axis_centre() {
let (out, y2) = zoom_view_about(
((0.0, 10.0, 0.0, 10.0), Some((0.0, 100.0))),
0.5,
(5.0, 5.0),
Some(50.0),
Scale::Linear,
Scale::Linear,
(true, true),
);
assert!(close(out, (2.5, 7.5, 2.5, 7.5)), "{out:?}");
let (lo, hi) = y2.expect("y2 present");
assert!(
(lo - 25.0).abs() <= 1e-9 && (hi - 75.0).abs() <= 1e-9,
"{lo} {hi}"
);
}
#[test]
fn zoom_view_about_disabled_axis_keeps_its_range() {
let view = ((0.0, 10.0, 0.0, 10.0), Some((0.0, 100.0)));
let (out, y2) = zoom_view_about(
view,
0.5,
(5.0, 5.0),
Some(50.0),
Scale::Linear,
Scale::Linear,
(false, true),
);
assert!(close(out, (0.0, 10.0, 2.5, 7.5)), "{out:?}");
assert_eq!(y2, Some((25.0, 75.0)));
let (out, y2) = zoom_view_about(
view,
0.5,
(5.0, 5.0),
Some(50.0),
Scale::Linear,
Scale::Linear,
(true, false),
);
assert!(close(out, (2.5, 7.5, 0.0, 10.0)), "{out:?}");
assert_eq!(y2, Some((0.0, 100.0)));
}
#[test]
fn zoom_view_about_keeps_y2_anchor_fraction_fixed() {
let y2_before = (0.0, 100.0);
let cy2 = 80.0;
let (_, y2) = zoom_view_about(
((0.0, 10.0, 0.0, 10.0), Some(y2_before)),
0.3,
(5.0, 5.0),
Some(cy2),
Scale::Linear,
Scale::Linear,
(true, true),
);
let (lo, hi) = y2.expect("y2 present");
let frac_before = (cy2 - y2_before.0) / (y2_before.1 - y2_before.0);
let frac_after = (cy2 - lo) / (hi - lo);
assert!((frac_before - frac_after).abs() <= 1e-9, "{lo} {hi}");
}
#[test]
fn zoom_view_about_without_centre_leaves_y2_unchanged() {
let (out, y2) = zoom_view_about(
((0.0, 10.0, 0.0, 10.0), Some((0.0, 100.0))),
0.5,
(5.0, 5.0),
None,
Scale::Linear,
Scale::Linear,
(true, true),
);
assert!(close(out, (2.5, 7.5, 2.5, 7.5)), "{out:?}");
assert_eq!(y2, Some((0.0, 100.0)));
}
#[test]
fn apply_pan_linear_offsets_by_fraction() {
let (lo, hi) = apply_pan(0.0, 10.0, 0.1, false);
assert!(
(lo - 1.0).abs() <= 1e-12 && (hi - 11.0).abs() <= 1e-12,
"{lo} {hi}"
);
}
#[test]
fn apply_pan_log_round_trips() {
let (lo, hi) = apply_pan(1.0, 100.0, 0.25, true);
let (lo2, hi2) = apply_pan(lo, hi, -0.25, true);
assert!(
(lo2 - 1.0).abs() <= 1e-9 && (hi2 - 100.0).abs() <= 1e-9,
"{lo2} {hi2}"
);
assert!((lo - 10f64.powf(0.5)).abs() <= 1e-9, "{lo}");
assert!((hi - 10f64.powf(2.5)).abs() <= 1e-9, "{hi}");
}
#[test]
fn apply_pan_log_nonpositive_min_falls_back_to_linear() {
let (lo, hi) = apply_pan(-1.0, 10.0, 0.1, true);
assert!(
(lo - 0.1).abs() <= 1e-12 && (hi - 11.1).abs() <= 1e-12,
"{lo} {hi}"
);
}
#[test]
fn box_zoom_orders_corners() {
let out = box_zoom(8.0, 1.0, 2.0, 9.0);
assert!(close(out, (2.0, 8.0, 1.0, 9.0)), "{out:?}");
}
#[test]
fn constrain_zoom_axes_keeps_disabled_axis_range() {
let zoomed = (2.0, 8.0, 1.0, 9.0);
let current = (0.0, 10.0, 0.0, 100.0);
assert!(close(
constrain_zoom_axes(zoomed, current, true, true),
zoomed
));
assert!(close(
constrain_zoom_axes(zoomed, current, false, true),
(0.0, 10.0, 1.0, 9.0)
));
assert!(close(
constrain_zoom_axes(zoomed, current, true, false),
(2.0, 8.0, 0.0, 100.0)
));
assert!(close(
constrain_zoom_axes(zoomed, current, false, false),
current
));
}
#[test]
fn wheel_factor_direction_and_neutral() {
assert!(wheel_zoom_factor(100.0) < 1.0);
assert!(wheel_zoom_factor(-100.0) > 1.0);
assert!((wheel_zoom_factor(0.0) - 1.0).abs() <= 1e-12);
}
#[test]
fn wheel_factor_one_notch_is_exactly_1_1() {
let notch = 40.0_f32;
assert!((wheel_zoom_factor(notch) - 1.0 / 1.1).abs() <= 1e-12);
assert!((wheel_zoom_factor(-notch) - 1.1).abs() <= 1e-12);
assert!((wheel_zoom_factor(-3.0 * notch) - 1.1f64.powi(3)).abs() <= 1e-9);
}
#[test]
fn wheel_factor_split_deltas_compose_to_one_notch() {
let parts = [13.0_f32, 9.5, 11.5, 6.0]; let composed: f64 = parts.iter().map(|&p| wheel_zoom_factor(p)).product();
assert!(
(composed - 1.0 / 1.1).abs() <= 1e-12,
"composed {composed} != 1/1.1"
);
}
#[test]
fn validity_rejects_collapsed_or_inverted() {
assert!(is_valid((0.0, 1.0, 0.0, 1.0)));
assert!(!is_valid((1.0, 1.0, 0.0, 1.0)));
assert!(!is_valid((0.0, 1.0, 2.0, 1.0)));
}
use crate::core::transform::Transform;
fn pick_transform() -> Transform {
Transform::new(0.0, 10.0, 0.0, 10.0, area_100())
}
fn di(data: (f64, f64), pixel: (f32, f32)) -> DrawInput {
DrawInput { data, pixel }
}
#[test]
fn rectangle_two_point_bounds() {
let mut s = DrawState::new(DrawMode::Rectangle);
assert!(s.on_press(di((8.0, 1.0), (80.0, 90.0))).is_none());
assert!(matches!(
s.on_move(di((2.0, 9.0), (20.0, 10.0))),
Some(DrawEvent::InProgress {
mode: DrawMode::Rectangle,
..
})
));
let fin = s
.on_release(di((2.0, 9.0), (20.0, 10.0)))
.expect("finished");
match fin {
DrawEvent::Finished {
mode: DrawMode::Rectangle,
params:
DrawParams::Rectangle {
x,
y,
width,
height,
},
} => {
assert_eq!((x, y), (2.0, 1.0));
assert_eq!((width, height), (6.0, 8.0));
}
other => panic!("{other:?}"),
}
assert!(!s.is_active());
}
#[test]
fn line_two_point_endpoints() {
let mut s = DrawState::new(DrawMode::Line);
s.on_press(di((1.0, 2.0), (10.0, 20.0)));
let fin = s
.on_release(di((3.0, 4.0), (30.0, 40.0)))
.expect("finished");
assert_eq!(
fin,
DrawEvent::Finished {
mode: DrawMode::Line,
params: DrawParams::Line {
start: (1.0, 2.0),
end: (3.0, 4.0),
},
}
);
}
#[test]
fn ellipse_params_from_drag() {
let (a, b) = ellipse_semi_axes((0.0, 0.0), (5.0, 0.0));
assert_eq!((a, b), (5.0, 0.0));
let center = (1.0, 2.0);
let point = (4.0, 6.0);
let (a, b) = ellipse_semi_axes(center, point);
let dx = point.0 - center.0;
let dy = point.1 - center.1;
let on_ellipse = dx * dx / (a * a) + dy * dy / (b * b);
assert!(
(on_ellipse - 1.0).abs() <= 1e-9,
"a={a} b={b} -> {on_ellipse}"
);
assert!(b > a, "a={a} b={b}");
let mut s = DrawState::new(DrawMode::Ellipse);
s.on_press(di(center, (10.0, 20.0)));
let fin = s.on_release(di(point, (40.0, 60.0))).expect("finished");
match fin {
DrawEvent::Finished {
mode: DrawMode::Ellipse,
params:
DrawParams::Ellipse {
center: c,
semi_axes,
},
} => {
assert_eq!(c, center);
assert!((semi_axes.0 - a).abs() <= 1e-12 && (semi_axes.1 - b).abs() <= 1e-12);
}
other => panic!("{other:?}"),
}
}
#[test]
fn ellipse_preview_has_full_ring() {
let mut s = DrawState::new(DrawMode::Ellipse);
s.on_press(di((0.0, 0.0), (0.0, 0.0)));
let ev = s.on_move(di((4.0, 6.0), (40.0, 60.0))).expect("progress");
match ev {
DrawEvent::InProgress { points, .. } => {
assert_eq!(points.len(), 27);
}
other => panic!("{other:?}"),
}
}
#[test]
fn hline_vline_capture_one_coordinate() {
let mut s = DrawState::new(DrawMode::HLine);
assert!(matches!(
s.on_press(di((3.0, 7.0), (30.0, 70.0))),
Some(DrawEvent::InProgress { .. })
));
let fin = s
.on_release(di((9.0, 7.5), (90.0, 75.0)))
.expect("finished");
assert_eq!(
fin,
DrawEvent::Finished {
mode: DrawMode::HLine,
params: DrawParams::HLine { y: 7.5 },
}
);
let mut s = DrawState::new(DrawMode::VLine);
s.on_press(di((3.0, 7.0), (30.0, 70.0)));
let fin = s
.on_release(di((4.2, 1.0), (42.0, 10.0)))
.expect("finished");
assert_eq!(
fin,
DrawEvent::Finished {
mode: DrawMode::VLine,
params: DrawParams::VLine { x: 4.2 },
}
);
}
#[test]
fn polygon_accumulates_vertices_and_closes_on_first_point() {
let mut s = DrawState::new(DrawMode::Polygon).with_close_threshold(4.0);
s.on_press(di((0.0, 0.0), (0.0, 0.0)));
s.on_release(di((10.0, 0.0), (100.0, 0.0)));
s.on_release(di((10.0, 10.0), (100.0, 100.0)));
s.on_move(di((0.05, 0.05), (2.0, 3.0)));
let fin = s.on_release(di((0.05, 0.05), (2.0, 3.0))).expect("closed");
match fin {
DrawEvent::Finished {
mode: DrawMode::Polygon,
params: DrawParams::Polygon { vertices },
} => {
assert_eq!(vertices, vec![(0.0, 0.0), (10.0, 0.0), (10.0, 10.0)]);
}
other => panic!("{other:?}"),
}
assert!(!s.is_active());
}
#[test]
fn validate_closes_in_progress_polygon_anywhere() {
let mut s = DrawState::new(DrawMode::Polygon).with_close_threshold(4.0);
s.on_press(di((0.0, 0.0), (0.0, 0.0)));
s.on_release(di((10.0, 0.0), (100.0, 0.0)));
s.on_release(di((10.0, 10.0), (100.0, 100.0)));
s.on_move(di((5.0, 5.0), (50.0, 50.0)));
let fin = s.validate().expect("validate closes");
match fin {
DrawEvent::Finished {
mode: DrawMode::Polygon,
params: DrawParams::Polygon { vertices },
} => {
assert_eq!(vertices, vec![(0.0, 0.0), (10.0, 0.0), (10.0, 10.0)]);
}
other => panic!("{other:?}"),
}
assert!(!s.is_active());
}
#[test]
fn validate_is_noop_without_a_real_polygon_or_in_other_modes() {
let mut s = DrawState::new(DrawMode::Polygon);
assert!(s.validate().is_none());
s.on_press(di((0.0, 0.0), (0.0, 0.0)));
assert!(s.validate().is_none());
assert!(s.is_active());
let mut r = DrawState::new(DrawMode::Rectangle);
r.on_press(di((0.0, 0.0), (0.0, 0.0)));
assert!(r.validate().is_none());
assert!(r.is_active());
}
#[test]
fn polygon_does_not_close_with_two_points() {
let mut s = DrawState::new(DrawMode::Polygon).with_close_threshold(4.0);
s.on_press(di((0.0, 0.0), (0.0, 0.0)));
let ev = s.on_release(di((0.0, 0.0), (0.0, 0.0))).expect("progress");
assert!(matches!(ev, DrawEvent::InProgress { .. }));
assert!(s.is_active());
}
#[test]
fn polygon_replaces_near_previous_vertex() {
let mut s = DrawState::new(DrawMode::Polygon).with_close_threshold(4.0);
s.on_press(di((0.0, 0.0), (0.0, 0.0)));
s.on_release(di((10.0, 0.0), (100.0, 0.0)));
s.on_release(di((10.2, 0.1), (102.0, 1.0)));
let preview = s.preview().expect("active");
assert_eq!(preview.len(), 3);
assert_eq!(preview[1], (10.0, 0.0));
assert_eq!(preview[2], (10.2, 0.1));
}
#[test]
fn freehand_accumulates_and_dedups() {
let mut s = DrawState::new(DrawMode::FreeHand);
assert!(matches!(
s.on_press(di((0.0, 0.0), (0.0, 0.0))),
Some(DrawEvent::InProgress {
mode: DrawMode::FreeHand,
..
})
));
s.on_move(di((1.0, 1.0), (10.0, 10.0)));
s.on_move(di((1.0, 1.0), (10.0, 10.0)));
s.on_move(di((2.0, 0.0), (20.0, 0.0)));
let fin = s
.on_release(di((3.0, 1.0), (30.0, 10.0)))
.expect("finished");
match fin {
DrawEvent::Finished {
mode: DrawMode::FreeHand,
params: DrawParams::FreeHand { vertices },
} => {
assert_eq!(
vertices,
vec![(0.0, 0.0), (1.0, 1.0), (2.0, 0.0), (3.0, 1.0)]
);
}
other => panic!("{other:?}"),
}
}
#[test]
fn freehand_release_does_not_duplicate_last() {
let mut s = DrawState::new(DrawMode::FreeHand);
s.on_press(di((0.0, 0.0), (0.0, 0.0)));
s.on_move(di((1.0, 1.0), (10.0, 10.0)));
let fin = s
.on_release(di((1.0, 1.0), (10.0, 10.0)))
.expect("finished");
match fin {
DrawEvent::Finished {
params: DrawParams::FreeHand { vertices },
..
} => assert_eq!(vertices, vec![(0.0, 0.0), (1.0, 1.0)]),
other => panic!("{other:?}"),
}
}
#[test]
fn cancel_drops_in_progress_draw() {
let mut s = DrawState::new(DrawMode::Polygon);
s.on_press(di((0.0, 0.0), (0.0, 0.0)));
assert!(s.is_active());
s.cancel();
assert!(!s.is_active());
assert!(s.preview().is_none());
}
#[test]
fn idle_move_and_release_are_noops() {
let mut s = DrawState::new(DrawMode::Rectangle);
assert!(s.on_move(di((1.0, 1.0), (10.0, 10.0))).is_none());
assert!(s.on_release(di((1.0, 1.0), (10.0, 10.0))).is_none());
}
#[test]
fn fill_mode_and_style_defaults() {
assert_eq!(FillMode::default(), FillMode::Hatch);
let s = SelectionStyle::default();
assert_eq!(s.fill, FillMode::Hatch);
let s = SelectionStyle::new(FillMode::Solid, egui::Color32::RED);
assert_eq!(s.fill, FillMode::Solid);
assert_eq!(s.color, egui::Color32::RED);
}
#[test]
fn hatch_lines_cover_rect() {
let rect = Rect::from_min_max(pos2(0.0, 0.0), pos2(40.0, 40.0));
let lines = hatch_lines(rect, 10.0);
assert!(!lines.is_empty());
for (a, b) in &lines {
assert!(rect.contains(*a) && rect.contains(*b), "{a:?} {b:?}");
let dx = b.x - a.x;
let dy = b.y - a.y;
assert!((dx.abs() - dy.abs()).abs() <= 1e-3, "dx={dx} dy={dy}");
}
}
#[test]
fn hatch_lines_degenerate_inputs_empty() {
let rect = Rect::from_min_max(pos2(0.0, 0.0), pos2(40.0, 40.0));
assert!(hatch_lines(rect, 0.0).is_empty());
assert!(hatch_lines(rect, -5.0).is_empty());
let zero = Rect::from_min_max(pos2(0.0, 0.0), pos2(0.0, 0.0));
assert!(hatch_lines(zero, 10.0).is_empty());
}
#[test]
fn cursor_shape_per_edge() {
let t = pick_transform();
assert_eq!(cursor_for_edge(RoiEdge::Left, &t), CursorShape::SizeHor);
assert_eq!(cursor_for_edge(RoiEdge::Right, &t), CursorShape::SizeHor);
assert_eq!(cursor_for_edge(RoiEdge::Top, &t), CursorShape::SizeVer);
assert_eq!(cursor_for_edge(RoiEdge::Bottom, &t), CursorShape::SizeVer);
assert_eq!(cursor_for_edge(RoiEdge::TopLeft, &t), CursorShape::SizeNwse);
assert_eq!(
cursor_for_edge(RoiEdge::BottomRight, &t),
CursorShape::SizeNwse
);
assert_eq!(
cursor_for_edge(RoiEdge::TopRight, &t),
CursorShape::SizeNesw
);
assert_eq!(
cursor_for_edge(RoiEdge::BottomLeft, &t),
CursorShape::SizeNesw
);
assert_eq!(
cursor_for_edge(RoiEdge::Vertex(0), &t),
CursorShape::SizeAll
);
assert_eq!(
cursor_for_edge(RoiEdge::Vertex(7), &t),
CursorShape::SizeAll
);
}
#[test]
fn cursor_shape_corner_diagonal_flips_under_single_axis_inversion() {
let mut inv_y = pick_transform();
inv_y.y.inverted = true;
assert_eq!(
cursor_for_edge(RoiEdge::TopLeft, &inv_y),
CursorShape::SizeNesw
);
assert_eq!(
cursor_for_edge(RoiEdge::BottomRight, &inv_y),
CursorShape::SizeNesw
);
assert_eq!(
cursor_for_edge(RoiEdge::TopRight, &inv_y),
CursorShape::SizeNwse
);
assert_eq!(
cursor_for_edge(RoiEdge::BottomLeft, &inv_y),
CursorShape::SizeNwse
);
assert_eq!(cursor_for_edge(RoiEdge::Left, &inv_y), CursorShape::SizeHor);
assert_eq!(cursor_for_edge(RoiEdge::Top, &inv_y), CursorShape::SizeVer);
let mut inv_xy = pick_transform();
inv_xy.x.inverted = true;
inv_xy.y.inverted = true;
assert_eq!(
cursor_for_edge(RoiEdge::TopLeft, &inv_xy),
CursorShape::SizeNwse
);
assert_eq!(
cursor_for_edge(RoiEdge::TopRight, &inv_xy),
CursorShape::SizeNesw
);
}
#[test]
fn cursor_for_grab_defaults_when_nothing_grabbed() {
let t = pick_transform();
assert_eq!(cursor_for_grab(None, &t), CursorShape::Default);
assert_eq!(
cursor_for_grab(Some(RoiEdge::Left), &t),
CursorShape::SizeHor
);
}
#[test]
fn cursor_shape_maps_to_egui_icon() {
assert_eq!(
CursorShape::SizeHor.to_egui(),
egui::CursorIcon::ResizeHorizontal
);
assert_eq!(
CursorShape::SizeVer.to_egui(),
egui::CursorIcon::ResizeVertical
);
assert_eq!(
CursorShape::SizeNwse.to_egui(),
egui::CursorIcon::ResizeNwSe
);
assert_eq!(
CursorShape::SizeNesw.to_egui(),
egui::CursorIcon::ResizeNeSw
);
assert_eq!(CursorShape::SizeAll.to_egui(), egui::CursorIcon::Move);
assert_eq!(CursorShape::Default.to_egui(), egui::CursorIcon::Default);
}
#[test]
fn mouse_button_maps_from_egui() {
assert_eq!(
MouseButton::from_egui(egui::PointerButton::Primary),
MouseButton::Left
);
assert_eq!(
MouseButton::from_egui(egui::PointerButton::Middle),
MouseButton::Middle
);
assert_eq!(
MouseButton::from_egui(egui::PointerButton::Secondary),
MouseButton::Right
);
assert_eq!(
MouseButton::from_egui(egui::PointerButton::Extra1),
MouseButton::Right
);
}
#[test]
fn pointer_event_maps_pixel_to_data() {
let t = pick_transform();
let ev = PlotPointerEvent::clicked(MouseButton::Left, &t, pos2(50.0, 50.0));
match ev {
PlotPointerEvent::Clicked {
button,
data,
pixel,
} => {
assert_eq!(button, MouseButton::Left);
assert!(
(data.0 - 5.0).abs() <= 1e-9 && (data.1 - 5.0).abs() <= 1e-9,
"{data:?}"
);
assert_eq!(pixel, (50.0, 50.0));
}
other => panic!("expected Clicked, got {other:?}"),
}
let ev = PlotPointerEvent::double_clicked(MouseButton::Left, &t, pos2(0.0, 100.0));
match ev {
PlotPointerEvent::DoubleClicked { data, pixel, .. } => {
assert!(data.0.abs() <= 1e-9 && data.1.abs() <= 1e-9, "{data:?}");
assert_eq!(pixel, (0.0, 100.0));
}
other => panic!("expected DoubleClicked, got {other:?}"),
}
}
#[test]
fn pointer_event_moved_carries_optional_button() {
let t = pick_transform();
let ev = PlotPointerEvent::moved(None, &t, pos2(50.0, 50.0));
assert!(matches!(ev, PlotPointerEvent::Moved { button: None, .. }));
let ev = PlotPointerEvent::moved(Some(MouseButton::Left), &t, pos2(50.0, 50.0));
assert!(matches!(
ev,
PlotPointerEvent::Moved {
button: Some(MouseButton::Left),
..
}
));
}
#[test]
fn limits_changed_carries_ranges() {
let ev = PlotPointerEvent::limits_changed((0.0, 10.0), (1.0, 5.0), Some((2.0, 8.0)));
assert_eq!(
ev,
PlotPointerEvent::LimitsChanged {
x: (0.0, 10.0),
y: (1.0, 5.0),
y2: Some((2.0, 8.0)),
}
);
let ev = PlotPointerEvent::limits_changed((0.0, 10.0), (1.0, 5.0), None);
assert!(matches!(
ev,
PlotPointerEvent::LimitsChanged { y2: None, .. }
));
}
#[test]
fn nearest_point_picks_closest_within_threshold() {
let t = pick_transform();
let pts = [(0.0, 0.0), (5.0, 5.0), (10.0, 10.0)];
let pick = nearest_point(&pts, &t, pos2(52.0, 47.0), 6.0).expect("a pick");
assert_eq!(pick.index, 1);
assert_eq!((pick.x, pick.y), (5.0, 5.0));
assert!(nearest_point(&pts, &t, pos2(52.0, 47.0), 2.0).is_none());
assert!(nearest_point(&[], &t, pos2(0.0, 0.0), 100.0).is_none());
}
#[test]
fn clamp_axis_leaves_normal_range_untouched() {
assert_eq!(clamp_axis_limits(-3.0, 5.0, false), (-3.0, 5.0));
assert_eq!(clamp_axis_limits(1.0, 1000.0, true), (1.0, 1000.0));
}
#[test]
fn clamp_axis_clamps_beyond_safe_values() {
let (lo, hi) = clamp_axis_limits(0.0, 1e40, false);
assert_eq!((lo, hi), (0.0, FLOAT32_SAFE_MAX));
let (lo, hi) = clamp_axis_limits(-1e40, 5.0, false);
assert_eq!((lo, hi), (FLOAT32_SAFE_MIN, 5.0));
let (lo, hi) = clamp_axis_limits(-10.0, 1000.0, true);
assert_eq!((lo, hi), (FLOAT32_MINPOS, 1000.0));
}
#[test]
fn clamp_axis_swaps_inverted_bounds() {
let (lo, hi) = clamp_axis_limits(5.0, -3.0, false);
assert_eq!((lo, hi), (-3.0, 5.0));
}
#[test]
fn clamp_axis_expands_equal_bounds() {
assert_eq!(clamp_axis_limits(0.0, 0.0, false), (-0.1, 0.1));
let (lo, hi) = clamp_axis_limits(10.0, 10.0, false);
assert!(
(lo - 9.0).abs() <= 1e-12 && (hi - 11.0).abs() <= 1e-12,
"{lo},{hi}"
);
let (lo, hi) = clamp_axis_limits(-10.0, -10.0, false);
assert!(
(lo - -11.0).abs() <= 1e-12 && (hi - -9.0).abs() <= 1e-12,
"{lo},{hi}"
);
}
#[test]
fn clamp_axis_nan_falls_to_lower_bound() {
let (lo, hi) = clamp_axis_limits(f64::NAN, 5.0, false);
assert!(lo.is_finite() && hi.is_finite());
assert_eq!((lo, hi), (FLOAT32_SAFE_MIN, 5.0));
let (lo, hi) = clamp_axis_limits(f64::NAN, f64::NAN, true);
assert!(lo.is_finite() && hi.is_finite() && hi > lo, "{lo},{hi}");
}
#[test]
fn clamp_limits_clamps_both_axes() {
let out = clamp_limits((-1e40, 1e40, 0.0, 0.0), false, false);
assert_eq!(out.0, FLOAT32_SAFE_MIN);
assert_eq!(out.1, FLOAT32_SAFE_MAX);
assert_eq!((out.2, out.3), (-0.1, 0.1));
}
#[test]
fn image_index_maps_cursor_to_pixel() {
let t = pick_transform();
assert_eq!(
image_index(&t, (0.0, 0.0), (1.0, 1.0), (10, 10), pos2(5.0, 95.0)),
Some((0, 0))
);
assert_eq!(
image_index(&t, (0.0, 0.0), (1.0, 1.0), (10, 10), pos2(55.0, 45.0)),
Some((5, 5))
);
assert!(image_index(&t, (0.0, 0.0), (1.0, 1.0), (10, 10), pos2(-5.0, 50.0)).is_none());
}
#[test]
fn marker_cursor_reflects_drag_dof() {
assert_eq!(marker_cursor(&Marker::vline(3.0)), CursorShape::SizeHor);
assert_eq!(marker_cursor(&Marker::hline(3.0)), CursorShape::SizeVer);
let p = Marker::point(1.0, 2.0);
assert_eq!(marker_cursor(&p), CursorShape::SizeAll);
let ph = Marker::point(1.0, 2.0).with_constraint(MarkerConstraint::Horizontal);
assert_eq!(marker_cursor(&ph), CursorShape::SizeVer);
let pv = Marker::point(1.0, 2.0).with_constraint(MarkerConstraint::Vertical);
assert_eq!(marker_cursor(&pv), CursorShape::SizeHor);
}
#[test]
fn marker_at_returns_topmost_draggable_index() {
let t = pick_transform();
let markers = vec![
Marker::point(5.0, 5.0).with_draggable(true),
Marker::point(5.0, 5.0).with_draggable(true),
];
assert_eq!(marker_at(&markers, &t, pos2(50.0, 50.0)), Some(1));
}
#[test]
fn marker_at_skips_non_draggable_even_when_hit() {
let t = pick_transform();
let markers = vec![
Marker::point(5.0, 5.0).with_draggable(true),
Marker::point(5.0, 5.0), ];
assert_eq!(marker_at(&markers, &t, pos2(50.0, 50.0)), Some(0));
}
#[test]
fn marker_at_none_when_nothing_hit() {
let t = pick_transform();
let markers = vec![Marker::point(5.0, 5.0).with_draggable(true)];
assert_eq!(marker_at(&markers, &t, pos2(90.0, 10.0)), None);
assert_eq!(marker_at(&[], &t, pos2(50.0, 50.0)), None);
}
#[test]
fn roi_draw_mode_per_kind() {
use DrawMode as D;
use RoiDrawKind as K;
assert_eq!(roi_draw_mode(K::Rect), D::Rectangle);
assert_eq!(roi_draw_mode(K::Ellipse), D::Ellipse);
assert_eq!(roi_draw_mode(K::Polygon), D::Polygon);
assert_eq!(roi_draw_mode(K::Point), D::Point);
assert_eq!(roi_draw_mode(K::Cross), D::Point);
assert_eq!(roi_draw_mode(K::Line), D::Line);
assert_eq!(roi_draw_mode(K::Circle), D::Line);
assert_eq!(roi_draw_mode(K::HRange), D::Line);
assert_eq!(roi_draw_mode(K::VRange), D::Line);
assert_eq!(roi_draw_mode(K::HLine), D::HLine);
assert_eq!(roi_draw_mode(K::VLine), D::VLine);
assert_eq!(roi_draw_mode(K::Arc), D::Line);
assert_eq!(roi_draw_mode(K::Band), D::Line);
}
#[test]
fn draw_mode_point_finishes_on_press() {
let mut s = DrawState::new(DrawMode::Point);
let ev = s.on_press(di((3.5, 7.25), (35.0, 27.5))).expect("finished");
assert_eq!(
ev,
DrawEvent::Finished {
mode: DrawMode::Point,
params: DrawParams::Point { x: 3.5, y: 7.25 },
}
);
assert!(!s.is_active());
assert!(s.on_move(di((9.0, 9.0), (90.0, 90.0))).is_none());
assert!(s.on_release(di((9.0, 9.0), (90.0, 90.0))).is_none());
assert!(s.preview().is_none());
}
#[test]
fn roi_from_draw_rect() {
let p = DrawParams::Rectangle {
x: 2.0,
y: 1.0,
width: 6.0,
height: 8.0,
};
assert_eq!(
roi_from_draw(RoiDrawKind::Rect, &p),
Some(Roi::Rect {
x: (2.0, 8.0),
y: (1.0, 9.0),
})
);
}
#[test]
fn roi_from_draw_line() {
let p = DrawParams::Line {
start: (1.0, 2.0),
end: (3.0, 4.0),
};
assert_eq!(
roi_from_draw(RoiDrawKind::Line, &p),
Some(Roi::Line {
start: (1.0, 2.0),
end: (3.0, 4.0),
})
);
}
#[test]
fn roi_from_draw_polygon() {
let p = DrawParams::Polygon {
vertices: vec![(0.0, 0.0), (4.0, 0.0), (4.0, 4.0)],
};
assert_eq!(
roi_from_draw(RoiDrawKind::Polygon, &p),
Some(Roi::Polygon {
vertices: vec![(0.0, 0.0), (4.0, 0.0), (4.0, 4.0)],
})
);
}
#[test]
fn roi_from_draw_point() {
let p = DrawParams::Point { x: 5.0, y: 6.0 };
assert_eq!(
roi_from_draw(RoiDrawKind::Point, &p),
Some(Roi::Point { x: 5.0, y: 6.0 })
);
}
#[test]
fn roi_from_draw_cross() {
let p = DrawParams::Point { x: 5.0, y: 6.0 };
assert_eq!(
roi_from_draw(RoiDrawKind::Cross, &p),
Some(Roi::Cross { center: (5.0, 6.0) })
);
}
#[test]
fn roi_from_draw_ellipse_radii_are_semi_axes() {
let p = DrawParams::Ellipse {
center: (1.0, 2.0),
semi_axes: (4.0, 2.5),
};
assert_eq!(
roi_from_draw(RoiDrawKind::Ellipse, &p),
Some(Roi::Ellipse {
center: (1.0, 2.0),
radii: (4.0, 2.5),
orientation: 0.0,
})
);
}
#[test]
fn roi_from_draw_circle_radius_is_distance() {
let p = DrawParams::Line {
start: (1.0, 1.0),
end: (4.0, 5.0), };
match roi_from_draw(RoiDrawKind::Circle, &p).expect("circle") {
Roi::Circle { center, radius } => {
assert_eq!(center, (1.0, 1.0));
assert!((radius - 5.0).abs() <= 1e-12, "{radius}");
}
other => panic!("{other:?}"),
}
}
#[test]
fn roi_from_draw_hrange_orders_ys() {
let p = DrawParams::Line {
start: (1.0, 7.0),
end: (9.0, 3.0),
};
assert_eq!(
roi_from_draw(RoiDrawKind::HRange, &p),
Some(Roi::HRange { y: (3.0, 7.0) })
);
}
#[test]
fn roi_from_draw_vrange_orders_xs() {
let p = DrawParams::Line {
start: (8.0, 1.0),
end: (2.0, 9.0),
};
assert_eq!(
roi_from_draw(RoiDrawKind::VRange, &p),
Some(Roi::VRange { x: (2.0, 8.0) })
);
}
#[test]
fn roi_from_draw_hline_vline_capture_the_single_position() {
assert_eq!(
roi_from_draw(RoiDrawKind::HLine, &DrawParams::HLine { y: 4.5 }),
Some(Roi::HLine { y: 4.5 })
);
assert_eq!(
roi_from_draw(RoiDrawKind::VLine, &DrawParams::VLine { x: -2.0 }),
Some(Roi::VLine { x: -2.0 })
);
assert_eq!(
roi_from_draw(
RoiDrawKind::HLine,
&DrawParams::Line {
start: (0.0, 0.0),
end: (1.0, 1.0)
}
),
None
);
}
#[test]
fn roi_from_draw_band_default_width_is_tenth_of_length() {
let p = DrawParams::Line {
start: (0.0, 0.0),
end: (10.0, 0.0), };
match roi_from_draw(RoiDrawKind::Band, &p).expect("band") {
Roi::Band { begin, end, width } => {
assert_eq!(begin, (0.0, 0.0));
assert_eq!(end, (10.0, 0.0));
assert!((width - 1.0).abs() <= 1e-12, "{width}");
}
other => panic!("{other:?}"),
}
}
#[test]
fn roi_from_draw_arc_matches_silx_default() {
let p = DrawParams::Line {
start: (0.0, 0.0),
end: (4.0, 0.0),
};
match roi_from_draw(RoiDrawKind::Arc, &p).expect("arc") {
Roi::Arc {
center,
radius,
weight,
start_angle,
end_angle,
} => {
assert!((center.0 - 2.0).abs() <= 1e-9, "cx={}", center.0);
assert!(
(center.1 - (-0.9632309002009949)).abs() <= 1e-9,
"cy={}",
center.1
);
assert!(
(radius - 2.219867961636912).abs() <= 1e-9,
"radius={radius}"
);
assert!((weight - 0.8).abs() <= 1e-9, "weight={weight}");
assert!(
(start_angle - 2.692760559012144).abs() <= 1e-9,
"start={start_angle}"
);
assert!(
(end_angle - 0.4488320945776491).abs() <= 1e-9,
"end={end_angle}"
);
}
other => panic!("{other:?}"),
}
}
#[test]
fn arc_from_three_points_fits_the_circumcircle() {
match arc_from_three_points((1.0, 0.0), (0.0, 1.0), (-1.0, 0.0), 0.0) {
Roi::Arc {
center,
radius,
weight,
start_angle,
end_angle,
} => {
assert!(
center.0.abs() <= 1e-9 && center.1.abs() <= 1e-9,
"{center:?}"
);
assert!((radius - 1.0).abs() <= 1e-9, "radius={radius}");
assert!(weight.abs() <= 1e-9, "weight={weight}");
assert!(start_angle.abs() <= 1e-9, "start={start_angle}");
assert!(
(end_angle - std::f64::consts::PI).abs() <= 1e-9,
"end={end_angle}"
);
}
other => panic!("{other:?}"),
}
}
#[test]
fn arc_from_three_points_closed_circle_when_start_equals_end() {
match arc_from_three_points((2.0, 0.0), (-2.0, 0.0), (2.0, 0.0), 0.0) {
Roi::Arc {
center,
radius,
start_angle,
end_angle,
..
} => {
assert!(
center.0.abs() <= 1e-9 && center.1.abs() <= 1e-9,
"{center:?}"
);
assert!((radius - 2.0).abs() <= 1e-9, "radius={radius}");
assert!(
(end_angle - start_angle - std::f64::consts::TAU).abs() <= 1e-9,
"sweep={}",
end_angle - start_angle
);
}
other => panic!("{other:?}"),
}
}
#[test]
fn arc_from_three_points_collinear_degenerates() {
match arc_from_three_points((0.0, 0.0), (1.0, 0.0), (2.0, 0.0), 0.5) {
Roi::Arc {
center,
radius,
weight,
..
} => {
assert_eq!(center, (1.0, 0.0));
assert_eq!(radius, 0.0);
assert_eq!(weight, 0.0);
}
other => panic!("{other:?}"),
}
}
#[test]
fn arc_three_point_drag_refits_center_radius_and_keeps_weight() {
let arc = arc_from_three_points((1.0, 0.0), (0.0, 1.0), (-1.0, 0.0), 0.4);
let reshaped = arc_three_point_drag(&arc, ArcControlPoint::Mid, (0.0, 2.0));
match reshaped {
Roi::Arc {
center,
radius,
weight,
..
} => {
assert!(center.0.abs() <= 1e-9, "cx={}", center.0);
assert!((center.1 - 0.75).abs() <= 1e-9, "cy={}", center.1);
assert!((radius - 1.25).abs() <= 1e-9, "r={radius}");
assert!((weight - 0.4).abs() <= 1e-9, "weight={weight}");
}
other => panic!("{other:?}"),
}
}
#[test]
fn arc_three_point_drag_is_noop_on_non_arc() {
let rect = Roi::Rect {
x: (0.0, 1.0),
y: (0.0, 1.0),
};
assert_eq!(
arc_three_point_drag(&rect, ArcControlPoint::Start, (5.0, 5.0)),
rect
);
}
#[test]
fn roi_key_action_maps_silx_bindings_only() {
use egui::Key;
assert_eq!(
roi_key_action(Key::Enter, false),
Some(RoiKeyAction::Validate)
);
assert_eq!(
roi_key_action(Key::Delete, false),
Some(RoiKeyAction::UndoLast)
);
assert_eq!(
roi_key_action(Key::Backspace, false),
Some(RoiKeyAction::UndoLast)
);
assert_eq!(roi_key_action(Key::Z, true), Some(RoiKeyAction::UndoLast));
assert_eq!(roi_key_action(Key::Z, false), None);
assert_eq!(roi_key_action(Key::R, false), None);
assert_eq!(roi_key_action(Key::E, true), None);
}
#[test]
fn roi_from_draw_rejects_impossible_pairings() {
let line = DrawParams::Line {
start: (0.0, 0.0),
end: (1.0, 1.0),
};
assert_eq!(roi_from_draw(RoiDrawKind::Rect, &line), None);
assert_eq!(roi_from_draw(RoiDrawKind::Point, &line), None);
let hline = DrawParams::HLine { y: 3.0 };
assert_eq!(roi_from_draw(RoiDrawKind::HRange, &hline), None);
}
#[test]
fn roi_grab_at_edge_then_body_then_none() {
let t = pick_transform();
let rois = vec![ManagedRoi::new(Roi::Rect {
x: (2.0, 8.0),
y: (3.0, 7.0),
})];
assert_eq!(
roi_grab_at(&rois, &t, pos2(21.0, 50.0), 4.0),
Some((0, RoiGrab::Edge(RoiEdge::Left)))
);
assert_eq!(
roi_grab_at(&rois, &t, pos2(50.0, 50.0), 4.0),
Some((0, RoiGrab::Translate))
);
assert_eq!(roi_grab_at(&rois, &t, pos2(95.0, 95.0), 4.0), None);
}
#[test]
fn roi_grab_at_topmost_wins_with_overlap() {
let t = pick_transform();
let rois = vec![
ManagedRoi::new(Roi::Rect {
x: (1.0, 9.0),
y: (1.0, 9.0),
}),
ManagedRoi::new(Roi::Rect {
x: (2.0, 8.0),
y: (2.0, 8.0),
}),
];
assert_eq!(
roi_grab_at(&rois, &t, pos2(50.0, 50.0), 4.0),
Some((1, RoiGrab::Translate))
);
}
#[test]
fn roi_grab_at_skips_hidden_rois() {
let t = pick_transform();
let mut rois = vec![
ManagedRoi::new(Roi::Rect {
x: (1.0, 9.0),
y: (1.0, 9.0),
}),
ManagedRoi::new(Roi::Rect {
x: (2.0, 8.0),
y: (2.0, 8.0),
}),
];
rois[1].visible = false;
assert_eq!(
roi_grab_at(&rois, &t, pos2(50.0, 50.0), 4.0),
Some((0, RoiGrab::Translate))
);
assert_eq!(
roi_grab_at(&rois, &t, pos2(21.0, 50.0), 4.0),
Some((0, RoiGrab::Translate)),
"a hidden ROI's handles must not grab"
);
rois[0].visible = false;
assert_eq!(roi_grab_at(&rois, &t, pos2(50.0, 50.0), 4.0), None);
}
fn managed_arc(mode: RoiInteractionMode) -> ManagedRoi {
let mut m = ManagedRoi::new(Roi::Arc {
center: (5.0, 5.0),
radius: 2.0,
weight: 2.0,
start_angle: 0.0,
end_angle: std::f64::consts::PI,
});
assert!(m.set_interaction_mode(mode));
m
}
#[test]
fn arc_is_three_point_only_for_threepoint_arc() {
assert!(arc_is_three_point(&managed_arc(
RoiInteractionMode::ArcThreePoint
)));
assert!(!arc_is_three_point(&managed_arc(
RoiInteractionMode::ArcPolar
)));
assert!(!arc_is_three_point(&ManagedRoi::new(Roi::Rect {
x: (0.0, 1.0),
y: (0.0, 1.0),
})));
}
#[test]
fn arc_three_point_handles_are_the_control_points() {
let arc = Roi::Arc {
center: (5.0, 5.0),
radius: 2.0,
weight: 2.0,
start_angle: 0.0,
end_angle: std::f64::consts::PI,
};
let h = arc_three_point_handles(&arc).expect("arc has three-point handles");
assert!(h.iter().all(|h| h.kind == HandleKind::Vertex));
let near =
|a: [f64; 2], b: [f64; 2]| (a[0] - b[0]).abs() < 1e-9 && (a[1] - b[1]).abs() < 1e-9;
assert!(near(h[0].pos, [7.0, 5.0]), "start {:?}", h[0].pos);
assert!(near(h[1].pos, [5.0, 7.0]), "mid {:?}", h[1].pos);
assert!(near(h[2].pos, [3.0, 5.0]), "end {:?}", h[2].pos);
assert!(
arc_three_point_handles(&Roi::Rect {
x: (0.0, 1.0),
y: (0.0, 1.0),
})
.is_none()
);
}
#[test]
fn roi_grab_at_threepoint_grabs_control_points_polar_grabs_polar_handles() {
let t = pick_transform();
let tp = vec![managed_arc(RoiInteractionMode::ArcThreePoint)];
assert_eq!(
roi_grab_at(&tp, &t, pos2(50.0, 30.0), 4.0),
Some((0, RoiGrab::Edge(RoiEdge::Vertex(1)))),
"ThreePointMode grabs the mid control point"
);
assert_eq!(
roi_grab_at(&tp, &t, pos2(50.0, 20.0), 4.0),
Some((0, RoiGrab::Translate)),
"ThreePointMode does not expose the polar weight handle"
);
let polar = vec![managed_arc(RoiInteractionMode::ArcPolar)];
assert_eq!(
roi_grab_at(&polar, &t, pos2(50.0, 20.0), 4.0),
Some((0, RoiGrab::Edge(RoiEdge::Vertex(1)))),
"PolarMode grabs the polar weight handle"
);
}
#[test]
fn roi_apply_edge_drag_dispatches_on_interaction_mode() {
let to = (5.0, 9.0);
let mut tp = managed_arc(RoiInteractionMode::ArcThreePoint);
let base = tp.roi.clone();
roi_apply_edge_drag(&mut tp, RoiEdge::Vertex(1), to);
assert_eq!(
tp.roi,
arc_three_point_drag(&base, ArcControlPoint::Mid, to),
"ThreePointMode reshapes via the circumcircle"
);
let mut polar = managed_arc(RoiInteractionMode::ArcPolar);
let mut expected = polar.roi.clone();
roi_apply_edge_drag(&mut polar, RoiEdge::Vertex(1), to);
expected.move_edge(RoiEdge::Vertex(1), to);
assert_eq!(polar.roi, expected, "PolarMode edits via move_edge");
assert_ne!(
tp.roi, polar.roi,
"the interaction mode gates which edit a handle drag performs"
);
}
}