Skip to content

Commit 91e11df

Browse files
authored
Merge pull request #118 from ornlneutronimaging/claude/main
Add histogram view transforms with ROI remap
2 parents f8b16a4 + 1f25443 commit 91e11df

8 files changed

Lines changed: 665 additions & 127 deletions

File tree

rustpix-gui/src/app.rs

Lines changed: 223 additions & 74 deletions
Large diffs are not rendered by default.

rustpix-gui/src/state/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,5 +8,5 @@ pub use processing::ProcessingState;
88
pub use statistics::Statistics;
99
pub use ui::{
1010
ExportFormat, Hdf5ExportOptions, SpectrumXAxis, TiffBitDepth, TiffExportOptions,
11-
TiffSpectraTiming, TiffStackBehavior, UiState, ViewMode, ZoomMode,
11+
TiffSpectraTiming, TiffStackBehavior, UiState, ViewMode, ViewTransform, ZoomMode,
1212
};

rustpix-gui/src/state/ui.rs

Lines changed: 254 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,260 @@ pub struct UiHistogramView {
131131
pub show_grid: bool,
132132
/// Flag to trigger plot bounds reset (auto-fit to data).
133133
pub needs_plot_reset: bool,
134+
/// Current histogram view transform.
135+
pub transform: ViewTransform,
136+
}
137+
138+
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
139+
pub enum Rotation {
140+
#[default]
141+
R0,
142+
R90,
143+
R180,
144+
R270,
145+
}
146+
147+
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
148+
pub struct ViewTransform {
149+
pub rotation: Rotation,
150+
pub flip_h: bool,
151+
pub flip_v: bool,
152+
}
153+
154+
impl Default for ViewTransform {
155+
fn default() -> Self {
156+
Self {
157+
rotation: Rotation::R0,
158+
flip_h: false,
159+
flip_v: false,
160+
}
161+
}
162+
}
163+
164+
impl ViewTransform {
165+
#[must_use]
166+
pub fn is_identity(self) -> bool {
167+
self.rotation == Rotation::R0 && !self.flip_h && !self.flip_v
168+
}
169+
170+
pub fn rotate_cw(&mut self) {
171+
self.rotation = match self.rotation {
172+
Rotation::R0 => Rotation::R90,
173+
Rotation::R90 => Rotation::R180,
174+
Rotation::R180 => Rotation::R270,
175+
Rotation::R270 => Rotation::R0,
176+
};
177+
}
178+
179+
pub fn rotate_ccw(&mut self) {
180+
self.rotation = match self.rotation {
181+
Rotation::R0 => Rotation::R270,
182+
Rotation::R90 => Rotation::R0,
183+
Rotation::R180 => Rotation::R90,
184+
Rotation::R270 => Rotation::R180,
185+
};
186+
}
187+
188+
pub fn flip_horizontal(&mut self) {
189+
self.flip_h = !self.flip_h;
190+
}
191+
192+
pub fn flip_vertical(&mut self) {
193+
self.flip_v = !self.flip_v;
194+
}
195+
196+
pub fn reset(&mut self) {
197+
*self = Self::default();
198+
}
199+
200+
#[must_use]
201+
pub fn display_size(self, width: usize, height: usize) -> (usize, usize) {
202+
match self.rotation {
203+
Rotation::R90 | Rotation::R270 => (height, width),
204+
_ => (width, height),
205+
}
206+
}
207+
208+
#[must_use]
209+
pub fn apply_inverse(
210+
self,
211+
x: usize,
212+
y: usize,
213+
width: usize,
214+
height: usize,
215+
) -> Option<(usize, usize)> {
216+
if width == 0 || height == 0 {
217+
return None;
218+
}
219+
let (disp_w, disp_h) = self.display_size(width, height);
220+
if x >= disp_w || y >= disp_h {
221+
return None;
222+
}
223+
let mut x = x;
224+
let mut y = y;
225+
if self.flip_h {
226+
x = disp_w.saturating_sub(1).saturating_sub(x);
227+
}
228+
if self.flip_v {
229+
y = disp_h.saturating_sub(1).saturating_sub(y);
230+
}
231+
let (src_x, src_y) = match self.rotation {
232+
Rotation::R0 => (x, y),
233+
Rotation::R90 => (y, height.saturating_sub(1).saturating_sub(x)),
234+
Rotation::R180 => (
235+
width.saturating_sub(1).saturating_sub(x),
236+
height.saturating_sub(1).saturating_sub(y),
237+
),
238+
Rotation::R270 => (width.saturating_sub(1).saturating_sub(y), x),
239+
};
240+
if src_x >= width || src_y >= height {
241+
return None;
242+
}
243+
Some((src_x, src_y))
244+
}
245+
246+
#[must_use]
247+
pub fn apply_f64(self, x: f64, y: f64, width: f64, height: f64) -> Option<(f64, f64)> {
248+
if !width.is_finite() || !height.is_finite() || width <= 0.0 || height <= 0.0 {
249+
return None;
250+
}
251+
let (mut out_x, mut out_y) = match self.rotation {
252+
Rotation::R0 => (x, y),
253+
Rotation::R90 => (height - y, x),
254+
Rotation::R180 => (width - x, height - y),
255+
Rotation::R270 => (y, width - x),
256+
};
257+
let (disp_w, disp_h) = match self.rotation {
258+
Rotation::R90 | Rotation::R270 => (height, width),
259+
_ => (width, height),
260+
};
261+
if self.flip_h {
262+
out_x = disp_w - out_x;
263+
}
264+
if self.flip_v {
265+
out_y = disp_h - out_y;
266+
}
267+
Some((out_x, out_y))
268+
}
269+
270+
#[must_use]
271+
pub fn apply_inverse_f64(self, x: f64, y: f64, width: f64, height: f64) -> Option<(f64, f64)> {
272+
if !width.is_finite() || !height.is_finite() || width <= 0.0 || height <= 0.0 {
273+
return None;
274+
}
275+
let (disp_w, disp_h) = match self.rotation {
276+
Rotation::R90 | Rotation::R270 => (height, width),
277+
_ => (width, height),
278+
};
279+
let mut x = x;
280+
let mut y = y;
281+
if self.flip_h {
282+
x = disp_w - x;
283+
}
284+
if self.flip_v {
285+
y = disp_h - y;
286+
}
287+
let (src_x, src_y) = match self.rotation {
288+
Rotation::R0 => (x, y),
289+
Rotation::R90 => (y, height - x),
290+
Rotation::R180 => (width - x, height - y),
291+
Rotation::R270 => (width - y, x),
292+
};
293+
Some((src_x, src_y))
294+
}
295+
296+
#[must_use]
297+
pub fn status_label(self) -> Option<String> {
298+
if self.is_identity() {
299+
return None;
300+
}
301+
let mut parts = Vec::new();
302+
match self.rotation {
303+
Rotation::R0 => {}
304+
Rotation::R90 => parts.push("Rot 90° CW".to_string()),
305+
Rotation::R180 => parts.push("Rot 180°".to_string()),
306+
Rotation::R270 => parts.push("Rot 90° CCW".to_string()),
307+
}
308+
match (self.flip_h, self.flip_v) {
309+
(true, true) => parts.push("Flip H+V".to_string()),
310+
(true, false) => parts.push("Flip H".to_string()),
311+
(false, true) => parts.push("Flip V".to_string()),
312+
(false, false) => {}
313+
}
314+
Some(parts.join(", "))
315+
}
316+
}
317+
318+
#[cfg(test)]
319+
mod tests {
320+
use super::{Rotation, ViewTransform};
321+
use std::collections::HashSet;
322+
323+
fn assert_close(a: f64, b: f64) {
324+
assert!((a - b).abs() < 1e-9, "expected {a} ≈ {b}");
325+
}
326+
327+
#[test]
328+
fn view_transform_apply_inverse_is_bijection() {
329+
let width = 4usize;
330+
let height = 3usize;
331+
let rotations = [Rotation::R0, Rotation::R90, Rotation::R180, Rotation::R270];
332+
for rotation in rotations {
333+
for flip_h in [false, true] {
334+
for flip_v in [false, true] {
335+
let transform = ViewTransform {
336+
rotation,
337+
flip_h,
338+
flip_v,
339+
};
340+
let (disp_w, disp_h) = transform.display_size(width, height);
341+
let mut seen = HashSet::with_capacity(width * height);
342+
for y in 0..disp_h {
343+
for x in 0..disp_w {
344+
let (sx, sy) = transform
345+
.apply_inverse(x, y, width, height)
346+
.expect("in-bounds coords must map");
347+
assert!(sx < width && sy < height);
348+
let idx = sy * width + sx;
349+
assert!(seen.insert(idx), "duplicate mapping for {idx}");
350+
}
351+
}
352+
assert_eq!(seen.len(), width * height);
353+
assert!(transform.apply_inverse(disp_w, 0, width, height).is_none());
354+
assert!(transform.apply_inverse(0, disp_h, width, height).is_none());
355+
}
356+
}
357+
}
358+
}
359+
360+
#[test]
361+
fn view_transform_f64_round_trip() {
362+
let width = 5.0;
363+
let height = 3.0;
364+
let points = [(0.0, 0.0), (0.5, 0.5), (1.25, 2.75), (4.2, 0.1), (5.0, 3.0)];
365+
let rotations = [Rotation::R0, Rotation::R90, Rotation::R180, Rotation::R270];
366+
for rotation in rotations {
367+
for flip_h in [false, true] {
368+
for flip_v in [false, true] {
369+
let transform = ViewTransform {
370+
rotation,
371+
flip_h,
372+
flip_v,
373+
};
374+
for (x, y) in points {
375+
let (dx, dy) = transform
376+
.apply_f64(x, y, width, height)
377+
.expect("valid dims");
378+
let (sx, sy) = transform
379+
.apply_inverse_f64(dx, dy, width, height)
380+
.expect("valid dims");
381+
assert_close(sx, x);
382+
assert_close(sy, y);
383+
}
384+
}
385+
}
386+
}
387+
}
134388
}
135389

136390
#[derive(Clone, Copy, Default)]

rustpix-gui/src/ui/control_panel.rs

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -164,23 +164,32 @@ impl RustpixApp {
164164
}
165165

166166
fn status_banner_text(&self, colors: ThemeColors) -> (String, Color32, bool) {
167+
let transform_label = self.ui_state.histogram_view.transform.status_label();
167168
if let Some(p) = &self.selected_file {
168169
let name = p.file_name().unwrap_or_default().to_string_lossy();
169170
if self.statistics.hit_count > 0 {
170-
(
171-
format!(
172-
"{} • {} hits",
173-
name,
174-
format_number(self.statistics.hit_count)
175-
),
176-
colors.text_muted,
177-
false,
178-
)
171+
let mut text = format!(
172+
"{} • {} hits",
173+
name,
174+
format_number(self.statistics.hit_count)
175+
);
176+
if let Some(label) = transform_label {
177+
text = format!("{text} • {label}");
178+
}
179+
(text, colors.text_muted, false)
179180
} else {
180-
(format!("{name}"), colors.text_muted, false)
181+
let mut text = format!("{name}");
182+
if let Some(label) = transform_label {
183+
text = format!("{text} • {label}");
184+
}
185+
(text, colors.text_muted, false)
181186
}
182187
} else {
183-
("No file loaded".to_string(), colors.text_primary, true)
188+
let mut text = "No file loaded".to_string();
189+
if let Some(label) = transform_label {
190+
text = format!("{text} • {label}");
191+
}
192+
(text, colors.text_primary, true)
184193
}
185194
}
186195

0 commit comments

Comments
 (0)