use std::io::{self, Read, Write};
use egui::Color32;
use crate::core::backend::ItemHandle;
use crate::widget::high_level::Plot2D;
use crate::widget::interaction::{
DrawEvent, DrawMode, DrawParams, DrawState, FillMode, SelectionStyle,
};
use crate::widget::plot_widget::{PlotResponse, feed_draw_state, paint_draw_preview};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum MaskTool {
None,
Pencil,
Eraser,
Rectangle,
Polygon,
Ellipse,
}
impl MaskTool {
pub(crate) fn draw_mode(self) -> Option<DrawMode> {
match self {
MaskTool::Rectangle => Some(DrawMode::Rectangle),
MaskTool::Ellipse => Some(DrawMode::Ellipse),
MaskTool::Polygon => Some(DrawMode::Polygon),
MaskTool::None | MaskTool::Pencil | MaskTool::Eraser => None,
}
}
}
pub(crate) const PENCIL_PREVIEW_SEGMENTS: usize = 13;
pub(crate) fn pencil_preview_circle(
center: (f64, f64),
radius: f64,
segments: usize,
) -> Vec<(f64, f64)> {
(0..segments)
.map(|i| {
let a = i as f64 * std::f64::consts::TAU / segments as f64;
(center.0 + radius * a.cos(), center.1 + radius * a.sin())
})
.collect()
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ThresholdMode {
Below,
Between,
Above,
}
const DEFAULT_HISTORY_DEPTH: usize = 10;
pub(crate) struct MaskHistory {
history: Vec<Vec<u8>>,
redo: Vec<Vec<u8>>,
depth: usize,
}
impl MaskHistory {
pub(crate) fn new(mask: &[u8]) -> Self {
Self {
history: vec![mask.to_vec()],
redo: Vec::new(),
depth: DEFAULT_HISTORY_DEPTH,
}
}
pub(crate) fn reset(&mut self, mask: &[u8]) {
self.history = vec![mask.to_vec()];
self.redo.clear();
}
pub(crate) fn commit(&mut self, mask: &[u8]) {
let differs = self.history.last().map(|last| last != mask).unwrap_or(true);
if self.history.is_empty() || !self.redo.is_empty() || differs {
self.redo.clear();
while self.history.len() >= self.depth {
self.history.remove(0);
}
self.history.push(mask.to_vec());
}
}
pub(crate) fn undo(&mut self) -> Option<Vec<u8>> {
if self.history.len() > 1 {
let popped = self.history.pop().expect("len > 1");
self.redo.push(popped);
Some(self.history.last().expect("len >= 1").clone())
} else {
None
}
}
pub(crate) fn redo(&mut self) -> Option<Vec<u8>> {
if let Some(snapshot) = self.redo.pop() {
self.history.push(snapshot.clone());
Some(snapshot)
} else {
None
}
}
pub(crate) fn can_undo(&self) -> bool {
self.history.len() > 1
}
pub(crate) fn can_redo(&self) -> bool {
!self.redo.is_empty()
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum H5PickerMode {
Load,
Save,
}
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct H5DatasetPicker {
path: std::path::PathBuf,
datasets: Vec<String>,
mode: H5PickerMode,
name: String,
error: Option<String>,
}
impl H5DatasetPicker {
fn for_load(path: std::path::PathBuf, datasets: Vec<String>) -> Self {
let name = datasets.first().cloned().unwrap_or_default();
Self {
path,
datasets,
mode: H5PickerMode::Load,
name,
error: None,
}
}
fn for_save(path: std::path::PathBuf, datasets: Vec<String>) -> Self {
Self {
path,
datasets,
mode: H5PickerMode::Save,
name: "mask".to_string(),
error: None,
}
}
fn resolved_path(&self) -> Option<String> {
let p = self.name.trim_start_matches('/');
if p.is_empty() {
None
} else {
Some(p.to_string())
}
}
}
pub struct MaskToolsWidget {
pub mask: Vec<u8>,
pub width: u32,
pub height: u32,
pub color: Color32,
pub level: u8,
pub alpha: f32,
overrides: Vec<Option<[u8; 3]>>,
pub active_tool: MaskTool,
pub brush_size: u32,
pub mask_state: bool,
pub threshold_mode: ThresholdMode,
pub threshold_min: f32,
pub threshold_max: f32,
history: MaskHistory,
mask_handle: Option<ItemHandle>,
is_dirty: bool,
last_pencil_pos: Option<(i64, i64)>,
stroke_do_mask: Option<bool>,
shape_draw: Option<DrawState>,
pending_h5: Option<H5DatasetPicker>,
}
impl MaskToolsWidget {
pub fn new(width: u32, height: u32) -> Self {
let mask = vec![0; (width * height) as usize];
let history = MaskHistory::new(&mask);
Self {
mask,
width,
height,
color: Color32::from_rgb(160, 160, 164),
level: 1,
alpha: 0.8,
overrides: vec![None; 256],
active_tool: MaskTool::None,
brush_size: 1,
mask_state: true,
threshold_mode: ThresholdMode::Below,
threshold_min: 0.0,
threshold_max: 0.0,
history,
mask_handle: None,
is_dirty: true, last_pencil_pos: None,
stroke_do_mask: None,
shape_draw: None,
pending_h5: None,
}
}
pub fn reset_geometry(&mut self, width: u32, height: u32) {
self.width = width;
self.height = height;
self.mask = vec![0; (width * height) as usize];
self.history.reset(&self.mask);
self.is_dirty = true;
self.last_pencil_pos = None;
self.stroke_do_mask = None;
self.shape_draw = None;
}
pub fn clear(&mut self) {
let level = self.level;
for cell in &mut self.mask {
if *cell == level {
*cell = 0;
}
}
self.is_dirty = true;
}
pub fn clear_all(&mut self) {
self.mask.fill(0);
self.is_dirty = true;
}
pub fn invert(&mut self) {
let level = self.level;
for cell in &mut self.mask {
if *cell == 0 {
*cell = level;
} else if *cell == level {
*cell = 0;
}
}
self.is_dirty = true;
}
pub fn commit(&mut self) {
self.history.commit(&self.mask);
}
pub fn undo(&mut self) -> bool {
if let Some(snapshot) = self.history.undo() {
self.mask = snapshot;
self.is_dirty = true;
true
} else {
false
}
}
pub fn redo(&mut self) -> bool {
if let Some(snapshot) = self.history.redo() {
self.mask = snapshot;
self.is_dirty = true;
true
} else {
false
}
}
pub fn reset_history(&mut self) {
self.history.reset(&self.mask);
}
pub fn can_undo(&self) -> bool {
self.history.can_undo()
}
pub fn can_redo(&self) -> bool {
self.history.can_redo()
}
pub fn set_transparency(&mut self, alpha: f32) {
self.alpha = alpha.clamp(0.0, 1.0);
self.is_dirty = true;
}
pub fn transparency(&self) -> f32 {
self.alpha
}
pub fn set_overlay_color_for_colormap(&mut self, colormap: &crate::core::colormap::Colormap) {
let [r, g, b, _] = colormap.cursor_color;
let color = Color32::from_rgb(r, g, b);
if self.color != color {
self.color = color;
self.is_dirty = true;
}
}
pub fn set_mask_colors(&mut self, rgb: [u8; 3], level: Option<u8>) {
match level {
None => self.overrides.iter_mut().for_each(|c| *c = Some(rgb)),
Some(l) => self.overrides[l as usize] = Some(rgb),
}
self.is_dirty = true;
}
pub fn reset_mask_colors(&mut self, level: Option<u8>) {
match level {
None => self.overrides.iter_mut().for_each(|c| *c = None),
Some(l) => self.overrides[l as usize] = None,
}
self.is_dirty = true;
}
pub fn current_mask_color(&self) -> Color32 {
match self.overrides.get(self.level as usize).copied().flatten() {
Some([r, g, b]) => Color32::from_rgb(r, g, b),
None => self.color,
}
}
pub fn apply(&mut self, plot: &mut Plot2D) {
if !self.is_dirty {
return;
}
let srgba = self.color.to_srgba_unmultiplied();
let base_rgb = [
srgba[0] as f32 / 255.0,
srgba[1] as f32 / 255.0,
srgba[2] as f32 / 255.0,
];
let overrides_f32: Vec<Option<[f32; 3]>> = self
.overrides
.iter()
.map(|c| {
c.map(|rgb| {
[
rgb[0] as f32 / 255.0,
rgb[1] as f32 / 255.0,
rgb[2] as f32 / 255.0,
]
})
})
.collect();
let lut = crate::core::colormap::mask_overlay_lut(
base_rgb,
&overrides_f32,
self.level,
self.alpha,
);
let rgba = mask_overlay_rgba(&self.mask, &lut);
if let Some(handle) = self.mask_handle {
if plot
.try_update_rgba_image(handle, self.width, self.height, &rgba)
.is_err()
{
plot.remove(handle);
self.mask_handle = None;
}
}
if self.mask_handle.is_none() {
if let Ok(handle) = plot.add_rgba_mask(self.width, self.height, &rgba) {
self.mask_handle = Some(handle);
}
}
if let Some(handle) = self.mask_handle {
let z = overlay_z_value(plot.active_image().map(|img| plot.item_z_value(img)));
plot.set_item_z(handle, z);
}
self.is_dirty = false;
}
pub fn show_toolbar(&mut self, ui: &mut egui::Ui) {
ui.horizontal_wrapped(|ui| {
ui.label("Mask:");
ui.selectable_value(&mut self.active_tool, MaskTool::None, "â—‹")
.on_hover_text("Disable masking");
ui.selectable_value(&mut self.active_tool, MaskTool::Pencil, "Pencil")
.on_hover_text("Draw mask (hold Ctrl to erase)");
ui.selectable_value(&mut self.active_tool, MaskTool::Eraser, "Eraser")
.on_hover_text("Erase mask (hold Ctrl to draw)");
ui.selectable_value(&mut self.active_tool, MaskTool::Rectangle, "Rectangle")
.on_hover_text("Draw a rectangular region with the Mask/Unmask state");
ui.selectable_value(&mut self.active_tool, MaskTool::Polygon, "Polygon")
.on_hover_text("Draw a polygonal region with the Mask/Unmask state");
ui.selectable_value(&mut self.active_tool, MaskTool::Ellipse, "Ellipse")
.on_hover_text("Draw an elliptical region with the Mask/Unmask state");
ui.separator();
ui.selectable_value(&mut self.mask_state, true, "Mask")
.on_hover_text("Drawing masks with current level. Press Ctrl to unmask");
ui.selectable_value(&mut self.mask_state, false, "Unmask")
.on_hover_text("Drawing unmasks with current level. Press Ctrl to mask");
ui.add(egui::Slider::new(&mut self.level, 1..=255).text("Mask level"));
let mut level_color = self.current_mask_color();
if ui
.color_edit_button_srgba(&mut level_color)
.on_hover_text("Overlay color for the current mask level")
.changed()
{
let [r, g, b, _] = level_color.to_srgba_unmultiplied();
self.set_mask_colors([r, g, b], Some(self.level));
}
if ui
.button("Reset color")
.on_hover_text("Reset the current level's color to the default")
.clicked()
{
self.reset_mask_colors(Some(self.level));
}
let mut alpha = self.alpha;
if ui
.add(egui::Slider::new(&mut alpha, 0.0..=1.0).text("Transparency"))
.changed()
{
self.set_transparency(alpha);
}
if self.active_tool != MaskTool::None {
ui.add(egui::Slider::new(&mut self.brush_size, 1..=50).text("Brush size"));
ui.add(
egui::DragValue::new(&mut self.brush_size)
.range(1..=1024)
.speed(1.0),
)
.on_hover_text("Brush width in pixels (1-1024)");
}
if ui
.add_enabled(self.can_undo(), egui::Button::new("Undo"))
.clicked()
{
self.undo();
}
if ui
.add_enabled(self.can_redo(), egui::Button::new("Redo"))
.clicked()
{
self.redo();
}
let invert_shortcut =
ui.input_mut(|i| i.consume_key(egui::Modifiers::COMMAND, egui::Key::I));
if ui
.button("Invert")
.on_hover_text("Invert current mask (Ctrl+I)")
.clicked()
|| invert_shortcut
{
self.invert();
self.commit();
}
if ui.button("Clear").clicked() {
self.clear();
self.commit();
}
if ui.button("Clear All").clicked() {
self.clear_all();
self.commit();
}
ui.separator();
if ui
.button("Load...")
.on_hover_text("Load mask from file")
.clicked()
{
let _ = self.load_mask_dialog();
}
if ui
.button("Save...")
.on_hover_text("Save mask to file")
.clicked()
{
let _ = self.save_mask_dialog();
}
});
self.show_h5_picker(ui);
}
fn effective_do_mask(base: bool, ctrl: bool) -> bool {
base ^ ctrl
}
pub fn handle_interaction(&mut self, plot_response: &PlotResponse) {
if !matches!(self.active_tool, MaskTool::Pencil | MaskTool::Eraser) {
self.end_pencil_stroke();
return;
}
let response = &plot_response.response;
let primary = egui::PointerButton::Primary;
let drawing = response.dragged_by(primary) || response.clicked_by(primary);
let finished = response.drag_stopped_by(primary) || response.clicked_by(primary);
if (drawing || finished)
&& let Some(pointer_pos) = response.interact_pointer_pos()
{
let (data_x, data_y) = plot_response.transform.pixel_to_data(pointer_pos);
let do_mask = *self.stroke_do_mask.get_or_insert_with(|| {
let ctrl = plot_response.response.ctx.input(|i| i.modifiers.command);
Self::effective_do_mask(self.active_tool == MaskTool::Pencil, ctrl)
});
let (row, col) = pencil_cell(data_x, data_y);
self.paint_pencil_point(row, col, do_mask);
}
if finished {
self.end_pencil_stroke();
}
}
fn paint_pencil_point(&mut self, row: i64, col: i64, do_mask: bool) {
if self.last_pencil_pos == Some((row, col)) {
return;
}
let level = self.level;
if let Some((last_row, last_col)) = self.last_pencil_pos {
self.update_line(
level,
(last_row, last_col),
(row, col),
self.brush_size as i64,
do_mask,
);
}
self.update_disk(level, row, col, self.brush_size as f32 / 2.0, do_mask);
self.last_pencil_pos = Some((row, col));
}
fn end_pencil_stroke(&mut self) {
self.last_pencil_pos = None;
self.stroke_do_mask = None;
}
pub(crate) fn cancel_shape_draw(&mut self) {
self.shape_draw = None;
}
pub(crate) fn shape_draw(&self) -> Option<&DrawState> {
self.shape_draw.as_ref()
}
pub(crate) fn handle_shape_draw(&mut self, plot_response: &PlotResponse) -> Option<DrawEvent> {
let Some(mode) = self.active_tool.draw_mode() else {
self.shape_draw = None;
return None;
};
if !matches!(&self.shape_draw, Some(d) if d.mode() == mode) {
self.shape_draw = Some(DrawState::new(mode));
}
let draw = self.shape_draw.as_mut().expect("armed above");
let event = feed_draw_state(draw, &plot_response.response, &plot_response.transform);
if let Some(DrawEvent::Finished { params, .. }) = &event {
let ctrl = plot_response.response.ctx.input(|i| i.modifiers.command);
let do_mask = Self::effective_do_mask(self.mask_state, ctrl);
self.fill_from_draw(params, do_mask);
self.shape_draw = Some(DrawState::new(mode));
}
event
}
pub fn handle_draw(&mut self, ui: &egui::Ui, plot_response: &PlotResponse) {
match self.active_tool {
MaskTool::Pencil | MaskTool::Eraser => {
self.cancel_shape_draw();
self.handle_interaction(plot_response);
self.paint_brush_preview(ui, plot_response);
}
MaskTool::Rectangle | MaskTool::Polygon | MaskTool::Ellipse => {
let event = self.handle_shape_draw(plot_response);
self.paint_shape_preview(ui, plot_response, event.as_ref());
}
MaskTool::None => {
self.cancel_shape_draw();
self.handle_interaction(plot_response);
}
}
}
pub(crate) fn paint_brush_preview(&self, ui: &egui::Ui, plot_response: &PlotResponse) {
if !matches!(self.active_tool, MaskTool::Pencil | MaskTool::Eraser) {
return;
}
let area = plot_response.transform.area;
let Some(cursor) = plot_response
.response
.hover_pos()
.or_else(|| plot_response.response.interact_pointer_pos())
else {
return;
};
if !area.contains(cursor) {
return;
}
let center = plot_response.transform.pixel_to_data(cursor);
let radius = self.brush_size as f64 / 2.0;
let circle = pencil_preview_circle(center, radius, PENCIL_PREVIEW_SEGMENTS);
let mut outline: Vec<egui::Pos2> = circle
.iter()
.map(|&(x, y)| plot_response.transform.data_to_pixel(x, y))
.collect();
if let Some(&first) = outline.first() {
outline.push(first); }
let painter = ui.ctx().layer_painter(egui::LayerId::new(
egui::Order::Foreground,
egui::Id::new("mask-brush-preview"),
));
painter.with_clip_rect(area).add(egui::Shape::dashed_line(
&outline,
egui::Stroke::new(1.5, self.color),
6.0,
4.0,
));
}
pub(crate) fn paint_shape_preview(
&self,
ui: &egui::Ui,
plot_response: &PlotResponse,
event: Option<&DrawEvent>,
) {
let Some(draw) = self.shape_draw() else {
return;
};
let style = SelectionStyle::new(FillMode::Hatch, self.color);
let painter = ui
.ctx()
.layer_painter(egui::LayerId::new(
egui::Order::Foreground,
egui::Id::new("mask-shape-preview"),
))
.with_clip_rect(plot_response.transform.area);
paint_draw_preview(&painter, &plot_response.transform, draw, event, style);
}
fn fill_from_draw(&mut self, params: &DrawParams, do_mask: bool) {
let level = self.level;
match params {
DrawParams::Rectangle {
x,
y,
width,
height,
} => {
let (row, col, h, w) = rect_params_to_cells(*x, *y, *width, *height);
self.update_rectangle(level, row, col, h, w, do_mask);
}
DrawParams::Ellipse { center, semi_axes } => {
let (crow, ccol, radius_r, radius_c) = ellipse_params_to_cells(*center, *semi_axes);
self.update_ellipse(level, crow, ccol, radius_r, radius_c, do_mask);
}
DrawParams::Polygon { vertices } => {
let cells = polygon_vertices_to_cells(vertices);
self.update_polygon(level, &cells, do_mask);
}
_ => return,
}
self.commit();
}
pub fn update_rectangle(
&mut self,
level: u8,
row: i64,
col: i64,
height: i64,
width: i64,
mask: bool,
) {
if row + height <= 0 || col + width <= 0 {
return;
}
let img_w = self.width as i64;
let img_h = self.height as i64;
let row_start = row.max(0);
let col_start = col.max(0);
let row_end = (row + height + 1).min(img_h);
let col_end = (col + width + 1).min(img_w);
for r in row_start..row_end {
for c in col_start..col_end {
let idx = (r as usize) * (self.width as usize) + (c as usize);
self.set_or_clear(idx, level, mask);
}
}
self.is_dirty = true;
}
pub fn update_polygon(&mut self, level: u8, vertices: &[(i64, i64)], mask: bool) {
let fill = polygon_fill_mask(vertices, self.height, self.width);
for (idx, &inside) in fill.iter().enumerate() {
if inside {
self.set_or_clear(idx, level, mask);
}
}
self.is_dirty = true;
}
pub fn update_points(&mut self, level: u8, rows: &[i64], cols: &[i64], mask: bool) {
let img_w = self.width as i64;
let img_h = self.height as i64;
for (&r, &c) in rows.iter().zip(cols.iter()) {
if r >= 0 && c >= 0 && r < img_h && c < img_w {
let idx = (r as usize) * (self.width as usize) + (c as usize);
self.set_or_clear(idx, level, mask);
}
}
self.is_dirty = true;
}
pub fn update_disk(&mut self, level: u8, crow: i64, ccol: i64, radius: f32, mask: bool) {
let (rows, cols) = circle_fill(crow, ccol, radius);
self.update_points(level, &rows, &cols, mask);
}
pub fn update_ellipse(
&mut self,
level: u8,
crow: i64,
ccol: i64,
radius_r: f32,
radius_c: f32,
mask: bool,
) {
let (rows, cols) = ellipse_fill(crow, ccol, radius_r, radius_c);
self.update_points(level, &rows, &cols, mask);
}
pub fn update_line(
&mut self,
level: u8,
from: (i64, i64),
to: (i64, i64),
width: i64,
mask: bool,
) {
let (rows, cols) = line_coords(from.0, from.1, to.0, to.1, width);
self.update_points(level, &rows, &cols, mask);
}
pub fn draw_line(&mut self, from: (i32, i32), to: (i32, i32), width: u32) {
let (col0, row0) = (from.0 as i64, from.1 as i64);
let (col1, row1) = (to.0 as i64, to.1 as i64);
self.update_line(
self.level,
(row0, col0),
(row1, col1),
width.max(1) as i64,
true,
);
}
pub fn update_stencil(&mut self, level: u8, stencil: &[bool], mask: bool) {
for (idx, &selected) in stencil.iter().enumerate() {
if selected {
self.set_or_clear(idx, level, mask);
}
}
self.is_dirty = true;
}
pub fn update_below_threshold(&mut self, level: u8, data: &[f32], threshold: f32, mask: bool) {
let stencil: Vec<bool> = data.iter().map(|&v| v < threshold).collect();
self.update_stencil(level, &stencil, mask);
}
pub fn update_between_thresholds(
&mut self,
level: u8,
data: &[f32],
min: f32,
max: f32,
mask: bool,
) {
let stencil: Vec<bool> = data.iter().map(|&v| min <= v && v <= max).collect();
self.update_stencil(level, &stencil, mask);
}
pub fn update_above_threshold(&mut self, level: u8, data: &[f32], threshold: f32, mask: bool) {
let stencil: Vec<bool> = data.iter().map(|&v| v > threshold).collect();
self.update_stencil(level, &stencil, mask);
}
pub fn update_threshold(&mut self, data: &[f32], mode: ThresholdMode, min: f32, max: f32) {
match mode {
ThresholdMode::Below => self.update_below_threshold(self.level, data, min, true),
ThresholdMode::Between => {
self.update_between_thresholds(self.level, data, min, max, true)
}
ThresholdMode::Above => self.update_above_threshold(self.level, data, max, true),
}
}
pub fn mask_not_finite(&mut self, data: &[f32]) {
let stencil: Vec<bool> = data.iter().map(|&v| !v.is_finite()).collect();
self.update_stencil(self.level, &stencil, true);
}
fn set_or_clear(&mut self, idx: usize, level: u8, mask: bool) {
if mask {
self.mask[idx] = level;
} else if self.mask[idx] == level {
self.mask[idx] = 0;
}
}
pub fn write_npy(&self, w: &mut impl Write) -> io::Result<()> {
write_npy_u8(w, self.height, self.width, &self.mask)
}
pub fn read_npy(&mut self, r: impl Read) -> io::Result<bool> {
let (height, width, data) = read_npy_u8(r)?;
Ok(self.apply_loaded_mask(height, width, data))
}
fn apply_loaded_mask(&mut self, height: u32, width: u32, data: Vec<u8>) -> bool {
let resized = height != self.height || width != self.width;
if resized {
let mut buf = vec![0u8; (self.width as usize) * (self.height as usize)];
let copy_h = self.height.min(height) as usize;
let copy_w = self.width.min(width) as usize;
for row in 0..copy_h {
let dst = row * self.width as usize;
let src = row * width as usize;
buf[dst..dst + copy_w].copy_from_slice(&data[src..src + copy_w]);
}
self.mask = buf;
} else {
self.mask = data;
}
self.commit();
self.is_dirty = true;
resized
}
pub fn write_edf(&self, w: &mut impl Write) -> io::Result<()> {
w.write_all(&crate::render::save::encode_mask_edf(
self.height,
self.width,
&self.mask,
))
}
pub fn read_edf(&mut self, mut r: impl Read) -> io::Result<bool> {
let mut bytes = Vec::new();
r.read_to_end(&mut bytes)?;
let (height, width, data) = crate::render::save::decode_mask_edf(&bytes)?;
Ok(self.apply_loaded_mask(height, width, data))
}
pub fn write_tiff(&self, w: &mut impl Write) -> io::Result<()> {
let bytes = crate::render::save::encode_mask_tiff(self.height, self.width, &self.mask)?;
w.write_all(&bytes)
}
pub fn read_tiff(&mut self, mut r: impl Read) -> io::Result<bool> {
let mut bytes = Vec::new();
r.read_to_end(&mut bytes)?;
let (height, width, data) = crate::render::save::decode_mask_tiff(&bytes)?;
Ok(self.apply_loaded_mask(height, width, data))
}
pub fn write_msk(&self, w: &mut impl Write) -> io::Result<()> {
w.write_all(&crate::render::save::encode_mask_msk(
self.height,
self.width,
&self.mask,
))
}
pub fn read_msk(&mut self, mut r: impl Read) -> io::Result<bool> {
let mut bytes = Vec::new();
r.read_to_end(&mut bytes)?;
let (height, width, data) = crate::render::save::decode_mask_msk(&bytes)?;
Ok(self.apply_loaded_mask(height, width, data))
}
pub fn save_npy(&self, path: impl AsRef<std::path::Path>) -> io::Result<()> {
let file = std::fs::File::create(path)?;
let mut writer = io::BufWriter::new(file);
self.write_npy(&mut writer)?;
writer.flush()
}
pub fn load_npy(&mut self, path: impl AsRef<std::path::Path>) -> io::Result<bool> {
let file = std::fs::File::open(path)?;
let reader = io::BufReader::new(file);
self.read_npy(reader)
}
pub fn save_mask_npy(&self, path: &str) -> io::Result<()> {
self.save_npy(path)
}
pub fn load_mask_npy(&mut self, path: &str) -> io::Result<bool> {
self.load_npy(path)
}
pub fn save_edf(&self, path: impl AsRef<std::path::Path>) -> io::Result<()> {
let file = std::fs::File::create(path)?;
let mut writer = io::BufWriter::new(file);
self.write_edf(&mut writer)?;
writer.flush()
}
pub fn load_edf(&mut self, path: impl AsRef<std::path::Path>) -> io::Result<bool> {
let file = std::fs::File::open(path)?;
let reader = io::BufReader::new(file);
self.read_edf(reader)
}
pub fn save_mask_edf(&self, path: &str) -> io::Result<()> {
self.save_edf(path)
}
pub fn load_mask_edf(&mut self, path: &str) -> io::Result<bool> {
self.load_edf(path)
}
pub fn save_tiff(&self, path: impl AsRef<std::path::Path>) -> io::Result<()> {
let file = std::fs::File::create(path)?;
let mut writer = io::BufWriter::new(file);
self.write_tiff(&mut writer)?;
writer.flush()
}
pub fn load_tiff(&mut self, path: impl AsRef<std::path::Path>) -> io::Result<bool> {
let file = std::fs::File::open(path)?;
let reader = io::BufReader::new(file);
self.read_tiff(reader)
}
pub fn save_mask_tiff(&self, path: &str) -> io::Result<()> {
self.save_tiff(path)
}
pub fn load_mask_tiff(&mut self, path: &str) -> io::Result<bool> {
self.load_tiff(path)
}
pub fn save_msk(&self, path: impl AsRef<std::path::Path>) -> io::Result<()> {
let file = std::fs::File::create(path)?;
let mut writer = io::BufWriter::new(file);
self.write_msk(&mut writer)?;
writer.flush()
}
pub fn load_msk(&mut self, path: impl AsRef<std::path::Path>) -> io::Result<bool> {
let file = std::fs::File::open(path)?;
let reader = io::BufReader::new(file);
self.read_msk(reader)
}
pub fn save_mask_msk(&self, path: &str) -> io::Result<()> {
self.save_msk(path)
}
pub fn load_mask_msk(&mut self, path: &str) -> io::Result<bool> {
self.load_msk(path)
}
pub fn save_h5(&self, path: impl AsRef<std::path::Path>) -> io::Result<()> {
crate::render::save::write_mask_hdf5(
path.as_ref(),
"mask",
self.height,
self.width,
&self.mask,
)
}
pub fn load_h5(&mut self, path: impl AsRef<std::path::Path>) -> io::Result<bool> {
let (height, width, data) = crate::render::save::read_mask_hdf5_auto(path.as_ref())?;
Ok(self.apply_loaded_mask(height, width, data))
}
pub fn load_h5_dataset(
&mut self,
path: impl AsRef<std::path::Path>,
data_path: &str,
) -> io::Result<bool> {
let (height, width, data) = crate::render::save::read_mask_hdf5(path.as_ref(), data_path)?;
Ok(self.apply_loaded_mask(height, width, data))
}
pub fn mask_datasets(&self, path: impl AsRef<std::path::Path>) -> io::Result<Vec<String>> {
crate::render::save::list_mask_datasets_hdf5(path.as_ref())
}
pub fn save_h5_dataset(
&self,
path: impl AsRef<std::path::Path>,
data_path: &str,
) -> io::Result<()> {
crate::render::save::write_mask_hdf5(
path.as_ref(),
data_path,
self.height,
self.width,
&self.mask,
)
}
pub fn begin_h5_load(&mut self, path: std::path::PathBuf) -> io::Result<()> {
let datasets = crate::render::save::list_mask_datasets_hdf5(&path)?;
self.pending_h5 = Some(H5DatasetPicker::for_load(path, datasets));
Ok(())
}
pub fn begin_h5_save(&mut self, path: std::path::PathBuf) -> io::Result<()> {
let datasets = if path.exists() {
crate::render::save::list_mask_datasets_hdf5(&path)?
} else {
Vec::new()
};
self.pending_h5 = Some(H5DatasetPicker::for_save(path, datasets));
Ok(())
}
pub(crate) fn apply_h5_pick(&mut self, picker: &H5DatasetPicker) -> io::Result<Option<bool>> {
let Some(data_path) = picker.resolved_path() else {
return Ok(None);
};
match picker.mode {
H5PickerMode::Load => Ok(Some(self.load_h5_dataset(&picker.path, &data_path)?)),
H5PickerMode::Save => {
self.save_h5_dataset(&picker.path, &data_path)?;
Ok(None)
}
}
}
fn show_h5_picker(&mut self, ui: &mut egui::Ui) {
let Some(mut picker) = self.pending_h5.take() else {
return;
};
let mut action: Option<bool> = None;
egui::Window::new("Select a 2D dataset")
.collapsible(false)
.resizable(false)
.anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0])
.show(ui.ctx(), |ui| {
ui.label(match picker.mode {
H5PickerMode::Load => "Select a dataset",
H5PickerMode::Save => "Select a dataset or type a new dataset name",
});
if picker.datasets.is_empty() {
ui.weak("(no 2D datasets in this file)");
} else {
egui::ScrollArea::vertical()
.max_height(160.0)
.show(ui, |ui| {
for ds in &picker.datasets {
ui.selectable_value(&mut picker.name, ds.clone(), ds);
}
});
}
if picker.mode == H5PickerMode::Save {
ui.horizontal(|ui| {
ui.label("Dataset:");
ui.text_edit_singleline(&mut picker.name);
});
}
if let Some(err) = &picker.error {
ui.colored_label(egui::Color32::RED, err);
}
ui.separator();
ui.horizontal(|ui| {
let can_ok = picker.resolved_path().is_some();
if ui.add_enabled(can_ok, egui::Button::new("OK")).clicked() {
action = Some(true);
}
if ui.button("Cancel").clicked() {
action = Some(false);
}
});
});
match action {
Some(true) => match self.apply_h5_pick(&picker) {
Ok(_) => {}
Err(e) => {
picker.error = Some(e.to_string());
self.pending_h5 = Some(picker);
}
},
Some(false) => {}
None => self.pending_h5 = Some(picker),
}
}
pub fn save_mask_h5(&self, path: &str) -> io::Result<()> {
self.save_h5(path)
}
pub fn load_mask_h5(&mut self, path: &str) -> io::Result<bool> {
self.load_h5(path)
}
pub fn save_mask_dialog(&mut self) -> io::Result<bool> {
let Some(path) = rfd::FileDialog::new()
.add_filter("NumPy mask", &["npy"])
.add_filter("EDF mask", &["edf"])
.add_filter("TIFF mask", &["tif", "tiff"])
.add_filter("HDF5 mask", &["h5", "nx5", "nxs", "hdf", "hdf5", "cxi"])
.add_filter("Fit2D mask", &["msk"])
.save_file()
else {
return Ok(false);
};
match resolve_mask_save_format(&path)? {
MaskFileFormat::Npy => self.save_npy(&path)?,
MaskFileFormat::Edf => self.save_edf(&path)?,
MaskFileFormat::Tiff => self.save_tiff(&path)?,
MaskFileFormat::H5 => {
self.begin_h5_save(path)?;
return Ok(false);
}
MaskFileFormat::Msk => self.save_msk(&path)?,
}
Ok(true)
}
pub fn load_mask_dialog(&mut self) -> io::Result<Option<bool>> {
let Some(path) = rfd::FileDialog::new()
.add_filter("NumPy mask", &["npy"])
.add_filter("EDF mask", &["edf"])
.add_filter("TIFF mask", &["tif", "tiff"])
.add_filter("HDF5 mask", &["h5", "nx5", "nxs", "hdf", "hdf5", "cxi"])
.add_filter("Fit2D mask", &["msk"])
.pick_file()
else {
return Ok(None);
};
let resized = match resolve_mask_load_format(&path)? {
MaskFileFormat::Npy => self.load_npy(&path)?,
MaskFileFormat::Edf => self.load_edf(&path)?,
MaskFileFormat::Tiff => self.load_tiff(&path)?,
MaskFileFormat::H5 => {
self.begin_h5_load(path)?;
return Ok(None);
}
MaskFileFormat::Msk => self.load_msk(&path)?,
};
Ok(Some(resized))
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum MaskFileFormat {
Npy,
Edf,
Tiff,
H5,
Msk,
}
fn mask_format_for_ext(ext: &str) -> Option<MaskFileFormat> {
match ext.to_ascii_lowercase().as_str() {
"npy" => Some(MaskFileFormat::Npy),
"edf" => Some(MaskFileFormat::Edf),
"tif" | "tiff" => Some(MaskFileFormat::Tiff),
"h5" | "nx5" | "nxs" | "hdf" | "hdf5" | "cxi" => Some(MaskFileFormat::H5),
"msk" => Some(MaskFileFormat::Msk),
_ => None,
}
}
fn resolve_mask_save_format(path: &std::path::Path) -> io::Result<MaskFileFormat> {
match path.extension().and_then(|e| e.to_str()) {
None => Ok(MaskFileFormat::Npy),
Some(ext) => mask_format_for_ext(ext).ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
format!("unsupported mask format: .{ext} (rsplot writes .npy/.edf/.tif/.h5/.msk)"),
)
}),
}
}
fn resolve_mask_load_format(path: &std::path::Path) -> io::Result<MaskFileFormat> {
path.extension()
.and_then(|e| e.to_str())
.and_then(mask_format_for_ext)
.ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
"unsupported mask format (rsplot reads .npy/.edf/.tif/.h5/.msk)",
)
})
}
fn mask_overlay_rgba(mask: &[u8], lut: &[[u8; 4]; 256]) -> Vec<[u8; 4]> {
mask.iter().map(|&level| lut[level as usize]).collect()
}
fn overlay_z_value(active_image_z: Option<f32>) -> f32 {
active_image_z.unwrap_or(0.0) + 1.0
}
fn write_npy_u8(w: &mut impl Write, height: u32, width: u32, data: &[u8]) -> io::Result<()> {
w.write_all(&crate::render::save::encode_mask_npy(height, width, data))
}
fn read_npy_u8(mut r: impl Read) -> io::Result<(u32, u32, Vec<u8>)> {
let mut bytes = Vec::new();
r.read_to_end(&mut bytes)?;
crate::render::save::decode_mask_npy(&bytes)
}
pub(crate) fn rect_params_to_cells(
x: f64,
y: f64,
width: f64,
height: f64,
) -> (i64, i64, i64, i64) {
(y as i64, x as i64, height.abs() as i64, width.abs() as i64)
}
pub(crate) fn pencil_cell(data_x: f64, data_y: f64) -> (i64, i64) {
(data_y as i64, data_x as i64)
}
pub(crate) fn ellipse_params_to_cells(
center: (f64, f64),
semi_axes: (f64, f64),
) -> (i64, i64, f32, f32) {
(
center.1 as i64, center.0 as i64, semi_axes.1 as f32, semi_axes.0 as f32, )
}
pub(crate) fn polygon_vertices_to_cells(vertices: &[(f64, f64)]) -> Vec<(i64, i64)> {
vertices
.iter()
.map(|&(x, y)| (y as i64, x as i64))
.collect()
}
pub fn polygon_fill_mask(vertices: &[(i64, i64)], height: u32, width: u32) -> Vec<bool> {
let height = height as i32;
let width = width as i32;
let mut mask = vec![false; (height.max(0) as usize) * (width.max(0) as usize)];
let nvert = vertices.len();
if nvert == 0 || height <= 0 || width <= 0 {
return mask;
}
let verts: Vec<(f32, f32)> = vertices
.iter()
.map(|&(r, c)| (r as f32, c as f32))
.collect();
let mut row_f_min = verts[0].0;
let mut row_f_max = verts[0].0;
for &(r, _) in &verts {
if r < row_f_min {
row_f_min = r;
}
if r > row_f_max {
row_f_max = r;
}
}
let row_min = (row_f_min as i32).max(0);
let row_max = ((row_f_max as i32) + 1).min(height);
for row in row_min..row_max {
let row_f = row as f32;
let (mut pt1y, mut pt1x) = verts[nvert - 1];
let mut col_min = width - 1;
let mut col_max = 0;
let mut is_inside: i32 = 0;
for &(pt2y, pt2x) in &verts {
if (pt1y <= row_f && row_f < pt2y) || (pt2y <= row_f && row_f < pt1y) {
let xinters =
(pt1x + (row_f - pt1y) * (pt2x - pt1x) / (pt2y - pt1y)).ceil() as i32 - 1;
if xinters < col_min {
col_min = xinters;
}
if xinters > col_max {
col_max = xinters;
}
if xinters < 0 {
is_inside ^= 1;
} else if xinters < width {
let idx = (row as usize) * (width as usize) + (xinters as usize);
mask[idx] = !mask[idx];
}
}
pt1y = pt2y;
pt1x = pt2x;
}
if col_min < col_max {
let col_min = col_min.max(0);
let col_max = col_max.min(width - 1);
for col in col_min..=col_max {
let idx = (row as usize) * (width as usize) + (col as usize);
let current = mask[idx] as i32;
mask[idx] = is_inside != 0;
is_inside ^= current;
}
}
}
mask
}
pub fn circle_fill(crow: i64, ccol: i64, radius: f32) -> (Vec<i64>, Vec<i64>) {
let radius = radius.abs();
let i_radius = radius as i64;
let r2 = radius * radius;
let lo = -i_radius;
let hi = radius.ceil() as i64;
let mut rows = Vec::new();
let mut cols = Vec::new();
for dr in lo..=hi {
for dc in lo..=hi {
let dr_f = dr as f32;
let dc_f = dc as f32;
if dr_f * dr_f + dc_f * dc_f < r2 {
rows.push(crow + dr);
cols.push(ccol + dc);
}
}
}
(rows, cols)
}
pub fn ellipse_fill(crow: i64, ccol: i64, radius_r: f32, radius_c: f32) -> (Vec<i64>, Vec<i64>) {
let i_radius_r = radius_r.abs() as i64;
let i_radius_c = radius_c.abs() as i64;
let rr2 = radius_r * radius_r;
let rc2 = radius_c * radius_c;
let r_lo = -i_radius_r;
let r_hi = radius_r.ceil() as i64;
let c_lo = -i_radius_c;
let c_hi = radius_c.ceil() as i64;
let mut rows = Vec::new();
let mut cols = Vec::new();
for dr in r_lo..=r_hi {
for dc in c_lo..=c_hi {
let dr_f = dr as f32;
let dc_f = dc as f32;
if dr_f * dr_f / rr2 + dc_f * dc_f / rc2 < 1.0 {
rows.push(crow + dr);
cols.push(ccol + dc);
}
}
}
(rows, cols)
}
pub fn line_coords(row0: i64, col0: i64, row1: i64, col1: i64, width: i64) -> (Vec<i64>, Vec<i64>) {
let dcol = (col1 - col0).abs();
let drow = (row1 - row0).abs();
let invert_coords = dcol < drow;
if dcol == 0 && drow == 0 {
return (vec![row0], vec![col0]);
}
let width = width.max(1);
let (da, db, step_a, step_b, a0, b0);
if !invert_coords {
da = dcol;
db = drow;
step_a = if col1 > col0 { 1 } else { -1 };
step_b = if row1 > row0 { 1 } else { -1 };
a0 = col0;
b0 = row0;
} else {
da = drow;
db = dcol;
step_a = if row1 > row0 { 1 } else { -1 };
step_b = if col1 > col0 { 1 } else { -1 };
a0 = row0;
b0 = col0;
}
let count = (da + 1) as usize;
let wsize = width as usize;
let mut a_coords = Vec::with_capacity(count * wsize);
let mut b_coords = Vec::with_capacity(count * wsize);
let mut a = a0;
let mut b = b0 - (width - 1) / 2;
let mut delta = 2 * db - da;
for _ in 0..count {
for offset in 0..width {
b_coords.push(b + offset);
a_coords.push(a);
}
if delta >= 0 {
b += step_b;
delta -= 2 * da;
}
a += step_a;
delta += 2 * db;
}
if !invert_coords {
(b_coords, a_coords)
} else {
(a_coords, b_coords)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pencil_cell_truncates_toward_zero_like_silx_int() {
assert_eq!(pencil_cell(2.7, 3.9), (3, 2));
assert_eq!(pencil_cell(-0.5, -0.5), (0, 0));
assert_eq!(pencil_cell(-1.5, 0.5), (0, -1));
assert_eq!(rect_params_to_cells(-0.5, -0.5, 1.0, 1.0).0, 0);
}
#[test]
fn overlay_color_adapts_to_the_image_colormap() {
use crate::core::colormap::{Colormap, ColormapName};
let mut mask = MaskToolsWidget::new(4, 4);
assert_eq!(mask.color, Color32::from_rgb(160, 160, 164), "placeholder");
mask.set_overlay_color_for_colormap(&Colormap::new(ColormapName::Gray, 0.0, 1.0));
assert_eq!(mask.color, Color32::from_rgb(255, 102, 255), "pink on gray");
mask.set_overlay_color_for_colormap(&Colormap::new(ColormapName::Inferno, 0.0, 1.0));
assert_eq!(mask.color, Color32::from_rgb(0, 255, 0), "green on inferno");
mask.level = 1;
mask.set_mask_colors([9, 8, 7], Some(1));
mask.set_overlay_color_for_colormap(&Colormap::new(ColormapName::Blue, 0.0, 1.0));
assert_eq!(mask.current_mask_color(), Color32::from_rgb(9, 8, 7));
assert_eq!(mask.color, Color32::from_rgb(255, 255, 0), "yellow on blue");
}
#[test]
fn mask_save_format_dispatches_by_extension_defaulting_to_npy() {
use std::path::Path;
assert_eq!(
resolve_mask_save_format(Path::new("m.npy")).unwrap(),
MaskFileFormat::Npy
);
assert_eq!(
resolve_mask_save_format(Path::new("m.edf")).unwrap(),
MaskFileFormat::Edf
);
assert_eq!(
resolve_mask_save_format(Path::new("M.EDF")).unwrap(),
MaskFileFormat::Edf
);
assert_eq!(
resolve_mask_save_format(Path::new("mask")).unwrap(),
MaskFileFormat::Npy
);
assert_eq!(
resolve_mask_save_format(Path::new("m.tif")).unwrap(),
MaskFileFormat::Tiff
);
assert_eq!(
resolve_mask_save_format(Path::new("M.TIFF")).unwrap(),
MaskFileFormat::Tiff
);
assert_eq!(
resolve_mask_save_format(Path::new("m.h5")).unwrap(),
MaskFileFormat::H5
);
assert_eq!(
resolve_mask_save_format(Path::new("m.NXS")).unwrap(),
MaskFileFormat::H5
);
assert_eq!(
resolve_mask_save_format(Path::new("m.msk")).unwrap(),
MaskFileFormat::Msk
);
assert_eq!(
resolve_mask_save_format(Path::new("M.MSK")).unwrap(),
MaskFileFormat::Msk
);
assert!(resolve_mask_save_format(Path::new("m.bogus")).is_err());
}
#[test]
fn mask_load_format_requires_a_known_extension() {
use std::path::Path;
assert_eq!(
resolve_mask_load_format(Path::new("m.npy")).unwrap(),
MaskFileFormat::Npy
);
assert_eq!(
resolve_mask_load_format(Path::new("m.edf")).unwrap(),
MaskFileFormat::Edf
);
assert_eq!(
resolve_mask_load_format(Path::new("m.Npy")).unwrap(),
MaskFileFormat::Npy
);
assert_eq!(
resolve_mask_load_format(Path::new("m.tiff")).unwrap(),
MaskFileFormat::Tiff
);
assert_eq!(
resolve_mask_load_format(Path::new("m.h5")).unwrap(),
MaskFileFormat::H5
);
assert_eq!(
resolve_mask_load_format(Path::new("m.HDF5")).unwrap(),
MaskFileFormat::H5
);
assert_eq!(
resolve_mask_load_format(Path::new("m.msk")).unwrap(),
MaskFileFormat::Msk
);
assert!(resolve_mask_load_format(Path::new("mask")).is_err());
assert!(resolve_mask_load_format(Path::new("m.bogus")).is_err());
}
#[test]
fn pencil_preview_circle_lies_on_radius_around_center() {
let c = (3.0, -2.0);
let r = 2.5;
let pts = pencil_preview_circle(c, r, PENCIL_PREVIEW_SEGMENTS);
assert_eq!(pts.len(), PENCIL_PREVIEW_SEGMENTS);
for (x, y) in &pts {
let d = ((x - c.0).powi(2) + (y - c.1).powi(2)).sqrt();
assert!((d - r).abs() < 1e-9, "point ({x},{y}) dist {d}");
}
assert!((pts[0].0 - (c.0 + r)).abs() < 1e-9, "first.x {}", pts[0].0);
assert!((pts[0].1 - c.1).abs() < 1e-9, "first.y {}", pts[0].1);
}
#[test]
fn clear_only_affects_current_level() {
let mut w = MaskToolsWidget::new(2, 2);
w.mask = vec![1, 2, 1, 0];
w.level = 1;
w.clear();
assert_eq!(w.mask, vec![0, 2, 0, 0]);
}
#[test]
fn clear_all_resets_every_level() {
let mut w = MaskToolsWidget::new(2, 2);
w.mask = vec![1, 2, 255, 0];
w.clear_all();
assert_eq!(w.mask, vec![0, 0, 0, 0]);
}
#[test]
fn undo_is_noop_with_only_baseline() {
let mut w = MaskToolsWidget::new(2, 2);
assert!(!w.can_undo());
assert!(!w.undo());
}
#[test]
fn commit_without_change_adds_no_snapshot() {
let mut w = MaskToolsWidget::new(2, 2);
w.commit(); assert!(!w.can_undo());
}
#[test]
fn undo_then_redo_round_trips_one_change() {
let mut w = MaskToolsWidget::new(2, 2);
w.mask = vec![1, 0, 0, 0];
w.commit();
assert!(w.can_undo());
assert!(w.undo());
assert_eq!(w.mask, vec![0, 0, 0, 0]); assert!(!w.can_undo());
assert!(w.can_redo());
assert!(w.redo());
assert_eq!(w.mask, vec![1, 0, 0, 0]); assert!(!w.can_redo());
}
#[test]
fn new_commit_after_undo_clears_redo() {
let mut w = MaskToolsWidget::new(2, 2);
w.mask = vec![1, 0, 0, 0];
w.commit();
assert!(w.undo());
assert!(w.can_redo());
w.mask = vec![2, 0, 0, 0];
w.commit();
assert!(!w.can_redo());
assert!(!w.redo());
}
#[test]
fn history_is_bounded_to_depth() {
let depth = DEFAULT_HISTORY_DEPTH;
let mut w = MaskToolsWidget::new(1, 1);
for level in 1..=(depth + 5) as u8 {
w.mask = vec![level];
w.commit();
}
let last = (depth + 5) as u8;
assert_eq!(w.mask, vec![last]);
let mut undos = 0;
while w.undo() {
undos += 1;
}
assert_eq!(undos, depth - 1);
assert_eq!(w.mask, vec![last - (depth as u8 - 1)]);
}
#[test]
fn invert_swaps_zero_and_current_level_only() {
let mut w = MaskToolsWidget::new(4, 1);
w.mask = vec![0, 1, 2, 0];
w.level = 1;
w.invert();
assert_eq!(w.mask, vec![1, 0, 2, 1]);
}
fn grid(mask: &[bool], width: usize) -> Vec<Vec<u8>> {
mask.chunks(width)
.map(|row| row.iter().map(|&b| b as u8).collect())
.collect()
}
#[test]
fn rectangle_fill_is_inclusive_of_both_edges() {
let mut w = MaskToolsWidget::new(4, 4);
w.update_rectangle(3, 1, 1, 1, 1, true);
let expected: Vec<u8> = vec![
0, 0, 0, 0, 0, 3, 3, 0, 0, 3, 3, 0, 0, 0, 0, 0, ];
assert_eq!(w.mask, expected);
}
#[test]
fn rect_params_to_cells_matches_silx_truncation() {
assert_eq!(rect_params_to_cells(2.7, 3.2, 4.9, 1.1), (3, 2, 1, 4));
assert_eq!(rect_params_to_cells(-0.5, -0.5, 2.0, 2.0), (0, 0, 2, 2));
}
#[test]
fn fill_from_draw_rectangle_masks_and_commits() {
let mut w = MaskToolsWidget::new(10, 10);
w.level = 1;
w.fill_from_draw(
&DrawParams::Rectangle {
x: 2.0,
y: 3.0,
width: 4.0,
height: 1.0,
},
true,
);
for r in 3..=4 {
for c in 2..=6 {
assert_eq!(
w.mask[(r * 10 + c) as usize],
1,
"cell ({r}, {c}) should be masked"
);
}
}
assert_eq!(w.mask[(2 * 10 + 2) as usize], 0, "row above must be clear");
assert_eq!(w.mask[(5 * 10 + 2) as usize], 0, "row below must be clear");
assert_eq!(w.mask[(3 * 10 + 7) as usize], 0, "col right must be clear");
assert!(w.can_undo(), "fill_from_draw must commit to undo history");
}
#[test]
fn effective_do_mask_xors_base_with_ctrl() {
assert!(
MaskToolsWidget::effective_do_mask(true, false),
"mask, no ctrl"
);
assert!(
!MaskToolsWidget::effective_do_mask(true, true),
"mask + ctrl -> unmask"
);
assert!(
!MaskToolsWidget::effective_do_mask(false, false),
"unmask, no ctrl"
);
assert!(
MaskToolsWidget::effective_do_mask(false, true),
"unmask + ctrl -> mask"
);
}
#[test]
fn fill_from_draw_unmasks_when_do_mask_false() {
let mut w = MaskToolsWidget::new(10, 10);
w.level = 1;
w.fill_from_draw(
&DrawParams::Rectangle {
x: 2.0,
y: 3.0,
width: 4.0,
height: 1.0,
},
true,
);
w.fill_from_draw(
&DrawParams::Rectangle {
x: 4.0,
y: 3.0,
width: 0.0,
height: 0.0,
},
false,
);
assert_eq!(w.mask[(3 * 10 + 4) as usize], 0, "unmasked cell is cleared");
assert_eq!(
w.mask[(3 * 10 + 2) as usize],
1,
"neighbouring masked cell is untouched"
);
}
#[test]
fn fill_from_draw_ignores_unwired_shapes() {
let mut w = MaskToolsWidget::new(4, 4);
w.fill_from_draw(&DrawParams::Point { x: 1.0, y: 1.0 }, true);
assert!(w.mask.iter().all(|&v| v == 0));
assert!(!w.can_undo(), "a no-op fill must not commit");
}
#[test]
fn ellipse_params_to_cells_maps_axes_to_row_col() {
let (crow, ccol, rr, rc) = ellipse_params_to_cells((5.7, 4.2), (3.0, 2.0));
assert_eq!((crow, ccol), (4, 5));
assert_eq!((rr, rc), (2.0_f32, 3.0_f32));
}
#[test]
fn fill_from_draw_ellipse_masks_and_commits() {
let mut w = MaskToolsWidget::new(12, 12);
w.level = 1;
w.fill_from_draw(
&DrawParams::Ellipse {
center: (5.0, 5.0),
semi_axes: (3.0, 2.0),
},
true,
);
let at = |r: i64, c: i64| w.mask[(r * 12 + c) as usize];
assert_eq!(at(5, 5), 1, "center is masked");
assert_eq!(at(5, 7), 1, "col offset 2 (< col radius 3) is masked");
assert_eq!(
at(7, 5),
0,
"row offset 2 (== row radius 2) is excluded (strict <)"
);
assert!(w.can_undo(), "fill_from_draw must commit");
}
#[test]
fn polygon_vertices_to_cells_swaps_xy_to_row_col() {
let cells = polygon_vertices_to_cells(&[(1.7, 2.3), (4.9, 0.1), (-0.5, 3.8)]);
assert_eq!(cells, vec![(2, 1), (0, 4), (3, 0)]);
}
#[test]
fn fill_from_draw_polygon_masks_interior_and_commits() {
let mut w = MaskToolsWidget::new(6, 6);
w.level = 1;
w.fill_from_draw(
&DrawParams::Polygon {
vertices: vec![(1.0, 1.0), (4.0, 1.0), (4.0, 4.0), (1.0, 4.0)],
},
true,
);
let at = |r: i64, c: i64| w.mask[(r * 6 + c) as usize];
assert_eq!(at(2, 2), 1, "interior cell is masked");
assert_eq!(at(0, 0), 0, "exterior corner stays unmasked");
assert_eq!(at(5, 5), 0, "exterior corner stays unmasked");
assert!(w.can_undo(), "fill_from_draw must commit");
}
#[test]
fn rectangle_fill_clips_to_image_and_skips_fully_outside() {
let mut w = MaskToolsWidget::new(3, 3);
w.update_rectangle(2, -1, -1, 2, 2, true);
let expected: Vec<u8> = vec![
2, 2, 0, 2, 2, 0, 0, 0, 0, ];
assert_eq!(w.mask, expected);
let mut w2 = MaskToolsWidget::new(3, 3);
w2.update_rectangle(2, -5, 0, 1, 1, true);
assert!(w2.mask.iter().all(|&v| v == 0));
}
#[test]
fn rectangle_unmask_only_clears_current_level() {
let mut w = MaskToolsWidget::new(2, 2);
w.mask = vec![1, 2, 1, 2];
w.update_rectangle(1, 0, 0, 1, 1, false);
assert_eq!(w.mask, vec![0, 2, 0, 2]);
}
#[test]
fn polygon_fills_triangle_interior() {
let vertices = [(1, 1), (4, 3), (1, 5), (2, 3)];
let mask = polygon_fill_mask(&vertices, 6, 8);
let expected = vec![
vec![0, 0, 0, 0, 0, 0, 0, 0],
vec![0, 0, 0, 0, 0, 0, 0, 0],
vec![0, 0, 1, 1, 1, 0, 0, 0],
vec![0, 0, 0, 1, 0, 0, 0, 0],
vec![0, 0, 0, 0, 0, 0, 0, 0],
vec![0, 0, 0, 0, 0, 0, 0, 0],
];
assert_eq!(grid(&mask, 8), expected);
}
#[test]
fn polygon_clips_when_partly_outside_image() {
let vertices = [(-1, -1), (4, 3), (1, 5), (2, 3)];
let mask = polygon_fill_mask(&vertices, 8, 6);
let expected = vec![
vec![1, 0, 0, 0, 0, 0],
vec![0, 1, 0, 0, 0, 0],
vec![0, 0, 1, 1, 1, 0],
vec![0, 0, 0, 1, 0, 0],
vec![0, 0, 0, 0, 0, 0],
vec![0, 0, 0, 0, 0, 0],
vec![0, 0, 0, 0, 0, 0],
vec![0, 0, 0, 0, 0, 0],
];
assert_eq!(grid(&mask, 6), expected);
}
#[test]
fn polygon_surrounding_mask_fills_nothing_on_xor_balance() {
let vertices = [
(-1, -1),
(-1, 7),
(7, 7),
(7, -1),
(0, -1),
(8, -2),
(8, 8),
(-2, 8),
];
let mask = polygon_fill_mask(&vertices, 6, 6);
assert!(
mask.iter().all(|&b| !b),
"surrounding polygon must be empty"
);
}
#[test]
fn circle_fill_radius_boundary_is_strict() {
let (rows, cols) = circle_fill(0, 0, 1.0);
assert_eq!((rows, cols), (vec![0], vec![0]));
let (rows, cols) = circle_fill(0, 0, 1.5);
let expected_rows = vec![-1, -1, -1, 0, 0, 0, 1, 1, 1];
let expected_cols = vec![-1, 0, 1, -1, 0, 1, -1, 0, 1];
assert_eq!(rows, expected_rows);
assert_eq!(cols, expected_cols);
}
#[test]
fn threshold_below_is_strict() {
let mut w = MaskToolsWidget::new(4, 1);
let data = [0.0_f32, 1.0, 2.0, 3.0];
w.update_below_threshold(1, &data, 2.0, true);
assert_eq!(w.mask, vec![1, 1, 0, 0]);
}
#[test]
fn threshold_between_is_inclusive() {
let mut w = MaskToolsWidget::new(5, 1);
let data = [0.0_f32, 1.0, 2.0, 3.0, 4.0];
w.update_between_thresholds(1, &data, 1.0, 3.0, true);
assert_eq!(w.mask, vec![0, 1, 1, 1, 0]);
}
#[test]
fn threshold_above_is_strict() {
let mut w = MaskToolsWidget::new(4, 1);
let data = [0.0_f32, 1.0, 2.0, 3.0];
w.update_above_threshold(1, &data, 2.0, true);
assert_eq!(w.mask, vec![0, 0, 0, 1]);
}
#[test]
fn threshold_dispatch_maps_bounds_per_mode() {
let data = [0.0_f32, 1.0, 2.0, 3.0];
let mut below = MaskToolsWidget::new(4, 1);
below.update_threshold(&data, ThresholdMode::Below, 2.0, 99.0);
assert_eq!(below.mask, vec![1, 1, 0, 0]);
let mut above = MaskToolsWidget::new(4, 1);
above.update_threshold(&data, ThresholdMode::Above, -99.0, 2.0);
assert_eq!(above.mask, vec![0, 0, 0, 1]);
let mut between = MaskToolsWidget::new(4, 1);
between.update_threshold(&data, ThresholdMode::Between, 1.0, 2.0);
assert_eq!(between.mask, vec![0, 1, 1, 0]);
}
#[test]
fn threshold_state_defaults_match_silx_group_box() {
let w = MaskToolsWidget::new(4, 1);
assert_eq!(w.threshold_mode, ThresholdMode::Below);
assert_eq!(w.threshold_min, 0.0);
assert_eq!(w.threshold_max, 0.0);
}
#[test]
fn threshold_unmask_only_clears_current_level() {
let mut w = MaskToolsWidget::new(4, 1);
w.mask = vec![1, 2, 1, 2];
let data = [0.0_f32, 1.0, 2.0, 3.0];
w.update_below_threshold(1, &data, 3.0, false);
assert_eq!(w.mask, vec![0, 2, 0, 2]);
}
#[test]
fn line_coords_diagonal_hits_every_cell() {
let (rows, cols) = line_coords(0, 0, 3, 3, 1);
assert_eq!(rows, vec![0, 1, 2, 3]);
assert_eq!(cols, vec![0, 1, 2, 3]);
}
#[test]
fn line_coords_single_point_is_degenerate() {
let (rows, cols) = line_coords(2, 5, 2, 5, 7);
assert_eq!(rows, vec![2]);
assert_eq!(cols, vec![5]);
}
#[test]
fn line_coords_width_thickens_minor_axis() {
let (rows, cols) = line_coords(1, 0, 1, 3, 2);
assert_eq!(rows, vec![1, 2, 1, 2, 1, 2, 1, 2]);
assert_eq!(cols, vec![0, 0, 1, 1, 2, 2, 3, 3]);
}
#[test]
fn draw_line_fills_gap_left_by_a_fast_drag() {
let mut w = MaskToolsWidget::new(4, 4);
w.level = 1;
w.draw_line((0, 0), (3, 3), 1);
let expected: Vec<u8> = vec![
1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, ];
assert_eq!(w.mask, expected);
}
#[test]
fn update_line_eraser_clears_only_current_level() {
let mut w = MaskToolsWidget::new(4, 4);
w.mask[0] = 1; w.mask[5] = 2; w.mask[10] = 1; w.mask[15] = 1; w.update_line(1, (0, 0), (3, 3), 1, false);
let expected: Vec<u8> = vec![
0, 0, 0, 0, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, ];
assert_eq!(w.mask, expected);
}
#[test]
fn pencil_stroke_interpolates_between_fast_drag_samples() {
let mut w = MaskToolsWidget::new(4, 4);
w.level = 1;
w.active_tool = MaskTool::Pencil;
w.paint_pencil_point(0, 0, true);
w.paint_pencil_point(3, 3, true);
let expected: Vec<u8> = vec![
1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, ];
assert_eq!(w.mask, expected);
}
#[test]
fn ending_a_stroke_prevents_connecting_to_the_next() {
let mut w = MaskToolsWidget::new(1, 4); w.level = 1;
w.active_tool = MaskTool::Pencil;
w.paint_pencil_point(0, 0, true);
w.end_pencil_stroke();
w.paint_pencil_point(3, 0, true);
assert_eq!(w.mask, vec![1, 0, 0, 1]);
}
#[test]
fn mask_not_finite_masks_nan_and_infinities_only() {
let mut w = MaskToolsWidget::new(6, 1);
w.level = 1;
let data = [
0.0_f32,
f32::NAN,
f32::INFINITY,
f32::NEG_INFINITY,
f32::MAX,
-1.5,
];
w.mask_not_finite(&data);
assert_eq!(w.mask, vec![0, 1, 1, 1, 0, 0]);
}
#[test]
fn mask_not_finite_uses_current_level() {
let mut w = MaskToolsWidget::new(2, 1);
w.level = 7;
let data = [f32::NAN, 3.0];
w.mask_not_finite(&data);
assert_eq!(w.mask, vec![7, 0]);
}
#[test]
fn npy_round_trips_through_memory() {
let mut src = MaskToolsWidget::new(3, 2); src.mask = vec![0, 1, 2, 3, 4, 5];
let mut buf = Vec::new();
src.write_npy(&mut buf).unwrap();
assert_eq!(&buf[0..6], b"\x93NUMPY");
assert_eq!(buf.len() % 64, 6);
let mut dst = MaskToolsWidget::new(3, 2);
let resized = dst.read_npy(buf.as_slice()).unwrap();
assert!(!resized, "same shape must not report a resize");
assert_eq!(dst.mask, vec![0, 1, 2, 3, 4, 5]);
}
#[test]
fn npy_load_crops_larger_mask() {
let mut big = MaskToolsWidget::new(3, 3);
big.mask = vec![
1, 2, 3, 4, 5, 6, 7, 8, 9, ];
let mut buf = Vec::new();
big.write_npy(&mut buf).unwrap();
let mut small = MaskToolsWidget::new(2, 2);
let resized = small.read_npy(buf.as_slice()).unwrap();
assert!(resized, "shape mismatch must report a resize");
assert_eq!(small.mask, vec![1, 2, 4, 5]);
}
#[test]
fn npy_load_pads_smaller_mask() {
let mut small = MaskToolsWidget::new(2, 2);
small.mask = vec![1, 2, 3, 4];
let mut buf = Vec::new();
small.write_npy(&mut buf).unwrap();
let mut big = MaskToolsWidget::new(3, 3);
let resized = big.read_npy(buf.as_slice()).unwrap();
assert!(resized);
assert_eq!(
big.mask,
vec![
1, 2, 0, 3, 4, 0, 0, 0, 0, ]
);
}
#[test]
fn npy_load_rejects_non_uint8_dtype() {
let header = b"{'descr': '<f8', 'fortran_order': False, 'shape': (1, 1), }";
let mut buf = Vec::new();
buf.extend_from_slice(b"\x93NUMPY");
buf.extend_from_slice(&[1u8, 0u8]);
buf.extend_from_slice(&(header.len() as u16).to_le_bytes());
buf.extend_from_slice(header);
buf.extend_from_slice(&[0u8; 8]);
let mut w = MaskToolsWidget::new(1, 1);
let err = w.read_npy(buf.as_slice()).unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::InvalidData);
}
#[test]
fn npy_load_commits_history() {
let mut src = MaskToolsWidget::new(2, 1);
src.mask = vec![1, 1];
let mut buf = Vec::new();
src.write_npy(&mut buf).unwrap();
let mut dst = MaskToolsWidget::new(2, 1);
assert!(!dst.can_undo());
dst.read_npy(buf.as_slice()).unwrap();
assert_eq!(dst.mask, vec![1, 1]);
assert!(dst.can_undo(), "load must commit to history");
}
#[test]
fn save_mask_npy_then_load_mask_npy_round_trips_via_path_string() {
let mut src = MaskToolsWidget::new(3, 2); src.mask = vec![0, 1, 2, 200, 254, 255];
let mut path = std::env::temp_dir();
path.push(format!("rsplot_mask_roundtrip_{}.npy", std::process::id()));
let path_str = path.to_str().expect("utf-8 temp path").to_string();
src.save_mask_npy(&path_str).expect("save");
let mut dst = MaskToolsWidget::new(3, 2);
let resized = dst.load_mask_npy(&path_str).expect("load");
assert!(!resized, "same shape must not report a resize");
assert_eq!(dst.mask, vec![0, 1, 2, 200, 254, 255]);
let on_disk = std::fs::read(&path_str).expect("read back file");
let expected = crate::render::save::encode_mask_npy(2, 3, &src.mask);
assert_eq!(on_disk, expected);
let _ = std::fs::remove_file(&path_str);
}
#[test]
fn edf_round_trips_via_read_write() {
let mut src = MaskToolsWidget::new(3, 2); src.mask = vec![0, 1, 2, 200, 254, 255];
let mut buf = Vec::new();
src.write_edf(&mut buf).unwrap();
let mut dst = MaskToolsWidget::new(3, 2);
let resized = dst.read_edf(buf.as_slice()).unwrap();
assert!(!resized, "same shape must not report a resize");
assert_eq!(dst.mask, vec![0, 1, 2, 200, 254, 255]);
assert!(dst.can_undo(), "load must commit to history");
}
#[test]
fn edf_load_crops_larger_mask() {
let mut big = MaskToolsWidget::new(3, 3);
big.mask = vec![
1, 2, 3, 4, 5, 6, 7, 8, 9, ];
let mut buf = Vec::new();
big.write_edf(&mut buf).unwrap();
let mut small = MaskToolsWidget::new(2, 2);
let resized = small.read_edf(buf.as_slice()).unwrap();
assert!(resized, "shape mismatch must report a resize");
assert_eq!(small.mask, vec![1, 2, 4, 5]);
}
#[test]
fn tiff_round_trips_via_read_write() {
let mut src = MaskToolsWidget::new(3, 2); src.mask = vec![0, 1, 2, 200, 254, 255];
let mut buf = Vec::new();
src.write_tiff(&mut buf).unwrap();
let mut dst = MaskToolsWidget::new(3, 2);
let resized = dst.read_tiff(buf.as_slice()).unwrap();
assert!(!resized, "same shape must not report a resize");
assert_eq!(dst.mask, vec![0, 1, 2, 200, 254, 255]);
assert!(dst.can_undo(), "load must commit to history");
}
#[test]
fn tiff_load_crops_larger_mask() {
let mut big = MaskToolsWidget::new(3, 3);
big.mask = vec![
1, 2, 3, 4, 5, 6, 7, 8, 9, ];
let mut buf = Vec::new();
big.write_tiff(&mut buf).unwrap();
let mut small = MaskToolsWidget::new(2, 2);
let resized = small.read_tiff(buf.as_slice()).unwrap();
assert!(resized, "shape mismatch must report a resize");
assert_eq!(small.mask, vec![1, 2, 4, 5]);
}
#[test]
fn msk_round_trips_as_binary_via_read_write() {
let mut src = MaskToolsWidget::new(5, 2); src.mask = vec![0, 3, 0, 250, 1, 0, 0, 7, 0, 0];
let mut buf = Vec::new();
src.write_msk(&mut buf).unwrap();
let mut dst = MaskToolsWidget::new(5, 2);
let resized = dst.read_msk(buf.as_slice()).unwrap();
assert!(!resized, "same shape must not report a resize");
assert_eq!(dst.mask, vec![0, 1, 0, 1, 1, 0, 0, 1, 0, 0]);
assert!(dst.can_undo(), "load must commit to history");
}
#[test]
fn msk_load_crops_larger_mask() {
let mut big = MaskToolsWidget::new(3, 3);
big.mask = vec![
1, 0, 9, 0, 5, 0, 7, 0, 2, ];
let mut buf = Vec::new();
big.write_msk(&mut buf).unwrap();
let mut small = MaskToolsWidget::new(2, 2);
let resized = small.read_msk(buf.as_slice()).unwrap();
assert!(resized, "shape mismatch must report a resize");
assert_eq!(small.mask, vec![1, 0, 0, 1]);
}
fn h5_temp_path(tag: &str) -> String {
let mut path = std::env::temp_dir();
path.push(format!("rsplot_mask_h5_{}_{}.h5", tag, std::process::id()));
let _ = std::fs::remove_file(&path);
path.to_str().expect("utf-8 temp path").to_string()
}
#[test]
fn h5_round_trips_via_save_load() {
let path = h5_temp_path("roundtrip");
let mut src = MaskToolsWidget::new(3, 2); src.mask = vec![0, 1, 2, 200, 254, 255];
src.save_h5(&path).expect("save h5");
let mut dst = MaskToolsWidget::new(3, 2);
let resized = dst.load_h5(&path).expect("load h5");
assert!(!resized, "same shape must not report a resize");
assert_eq!(dst.mask, vec![0, 1, 2, 200, 254, 255]);
assert!(dst.can_undo(), "load must commit to history");
let _ = std::fs::remove_file(&path);
}
#[test]
fn h5_load_crops_larger_mask() {
let path = h5_temp_path("crop");
let mut big = MaskToolsWidget::new(3, 3);
big.mask = vec![
1, 2, 3, 4, 5, 6, 7, 8, 9, ];
big.save_h5(&path).expect("save h5");
let mut small = MaskToolsWidget::new(2, 2);
let resized = small.load_h5(&path).expect("load h5");
assert!(resized, "shape mismatch must report a resize");
assert_eq!(small.mask, vec![1, 2, 4, 5]);
let _ = std::fs::remove_file(&path);
}
#[test]
fn h5_enumerates_then_loads_named_dataset() {
let path = h5_temp_path("select");
let mut src = MaskToolsWidget::new(2, 2);
src.mask = vec![10, 20, 30, 40];
src.save_h5(&path).expect("save h5");
let datasets = src.mask_datasets(&path).expect("enumerate datasets");
assert_eq!(datasets, vec!["mask".to_string()]);
let mut dst = MaskToolsWidget::new(2, 2);
let resized = dst
.load_h5_dataset(&path, &datasets[0])
.expect("load named dataset");
assert!(!resized);
assert_eq!(dst.mask, vec![10, 20, 30, 40]);
let _ = std::fs::remove_file(&path);
}
#[test]
fn h5_save_append_preserves_existing_dataset() {
use crate::render::save::{read_mask_hdf5, write_mask_hdf5};
let path = h5_temp_path("append");
let pathbuf = std::path::Path::new(&path);
write_mask_hdf5(pathbuf, "keep", 2, 2, &[5, 6, 7, 8]).expect("seed keep dataset");
let mut src = MaskToolsWidget::new(2, 2);
src.mask = vec![10, 20, 30, 40];
src.save_h5(&path).expect("append mask into existing file");
let mut datasets = src.mask_datasets(&path).expect("enumerate datasets");
datasets.sort();
assert_eq!(
datasets,
vec!["keep".to_string(), "mask".to_string()],
"append must preserve the pre-existing dataset alongside the mask"
);
let (_, _, keep) = read_mask_hdf5(pathbuf, "keep").expect("read preserved dataset");
assert_eq!(
keep,
vec![5, 6, 7, 8],
"pre-existing data must be untouched"
);
let (_, _, mask) = read_mask_hdf5(pathbuf, "mask").expect("read appended mask");
assert_eq!(mask, vec![10, 20, 30, 40]);
let _ = std::fs::remove_file(&path);
}
#[test]
fn h5_resave_overwrites_existing_mask_dataset() {
let path = h5_temp_path("resave");
let mut first = MaskToolsWidget::new(2, 2);
first.mask = vec![1, 1, 1, 1];
first.save_h5(&path).expect("first save");
let mut second = MaskToolsWidget::new(2, 2);
second.mask = vec![9, 8, 7, 6];
second.save_h5(&path).expect("re-save overwrites mask");
let mut dst = MaskToolsWidget::new(2, 2);
dst.load_h5(&path).expect("load h5");
assert_eq!(dst.mask, vec![9, 8, 7, 6], "re-save must replace the mask");
let _ = std::fs::remove_file(&path);
}
#[test]
fn h5_picker_load_lists_datasets_seeds_first_and_loads_choice() {
let path = h5_temp_path("pickload");
let pathbuf = std::path::Path::new(&path).to_path_buf();
let mut src = MaskToolsWidget::new(2, 2);
src.mask = vec![1, 2, 3, 4];
src.save_h5(&path).expect("write mask dataset");
crate::render::save::write_mask_hdf5(&pathbuf, "other", 2, 2, &[9, 9, 9, 9])
.expect("write other dataset");
let mut w = MaskToolsWidget::new(2, 2);
w.begin_h5_load(pathbuf.clone()).expect("begin load");
let picker = w.pending_h5.clone().expect("a load pick is pending");
assert_eq!(picker.mode, H5PickerMode::Load);
assert_eq!(
picker.datasets,
vec!["mask".to_string(), "other".to_string()]
);
assert_eq!(
picker.name, "mask",
"load seeds to the first dataset (sorted)"
);
let mut choose = picker;
choose.name = "other".to_string();
w.apply_h5_pick(&choose).expect("apply load");
assert_eq!(w.mask, vec![9, 9, 9, 9]);
let _ = std::fs::remove_file(&path);
}
#[test]
fn h5_picker_save_defaults_to_mask_and_writes_named_dataset() {
let path = h5_temp_path("picksave");
let pathbuf = std::path::Path::new(&path).to_path_buf();
let mut w = MaskToolsWidget::new(2, 2);
w.mask = vec![7, 7, 7, 7];
w.begin_h5_save(pathbuf.clone()).expect("begin save");
let mut picker = w.pending_h5.clone().expect("a save pick is pending");
assert_eq!(picker.mode, H5PickerMode::Save);
assert!(picker.datasets.is_empty(), "a new file has no datasets yet");
assert_eq!(picker.name, "mask", "save seeds to silx default 'mask'");
picker.name = "/custom".to_string();
w.apply_h5_pick(&picker).expect("apply save");
let (_, _, data) =
crate::render::save::read_mask_hdf5(&pathbuf, "custom").expect("read named dataset");
assert_eq!(data, vec![7, 7, 7, 7], "slash-stripped name written");
let _ = std::fs::remove_file(&path);
}
#[test]
fn h5_picker_resolved_path_strips_slashes_and_rejects_empty() {
let p = std::path::PathBuf::from("/tmp/x.h5");
let mut picker = H5DatasetPicker::for_save(p, Vec::new());
assert_eq!(picker.resolved_path().as_deref(), Some("mask"));
picker.name = "/leading/slash".to_string();
assert_eq!(picker.resolved_path().as_deref(), Some("leading/slash"));
picker.name = String::new();
assert_eq!(picker.resolved_path(), None);
picker.name = "///".to_string();
assert_eq!(
picker.resolved_path(),
None,
"all-slashes resolves to empty"
);
}
#[test]
fn save_mask_edf_then_load_mask_edf_round_trips_via_path_string() {
let mut src = MaskToolsWidget::new(3, 2); src.mask = vec![0, 1, 2, 200, 254, 255];
let mut path = std::env::temp_dir();
path.push(format!("rsplot_mask_roundtrip_{}.edf", std::process::id()));
let path_str = path.to_str().expect("utf-8 temp path").to_string();
src.save_mask_edf(&path_str).expect("save");
let mut dst = MaskToolsWidget::new(3, 2);
let resized = dst.load_mask_edf(&path_str).expect("load");
assert!(!resized, "same shape must not report a resize");
assert_eq!(dst.mask, vec![0, 1, 2, 200, 254, 255]);
let on_disk = std::fs::read(&path_str).expect("read back file");
let expected = crate::render::save::encode_mask_edf(2, 3, &src.mask);
assert_eq!(on_disk, expected);
let _ = std::fs::remove_file(&path_str);
}
#[test]
fn ellipse_fill_point_and_extent() {
let (rows, cols) = ellipse_fill(1, 1, 1.0, 1.0);
assert_eq!((rows, cols), (vec![1], vec![1]));
let (rows, cols) = ellipse_fill(0, 0, 20.0, 10.0);
assert_eq!(rows.len(), 617);
assert_eq!(cols.len(), 617);
let row_extent = rows.iter().max().unwrap() - rows.iter().min().unwrap();
let col_extent = cols.iter().max().unwrap() - cols.iter().min().unwrap();
assert!(
row_extent > col_extent,
"row radius 20 must span wider than col radius 10"
);
}
#[test]
fn default_overlay_color_is_silx_gray() {
let w = MaskToolsWidget::new(2, 2);
assert_eq!(w.color, Color32::from_rgb(160, 160, 164));
assert_eq!(w.alpha, 0.8);
assert_eq!(w.overrides.len(), 256);
assert!(w.overrides.iter().all(|c| c.is_none()));
}
#[test]
fn mask_overlay_rgba_maps_each_level_through_lut() {
let lut = crate::core::colormap::mask_overlay_lut(
[160.0 / 255.0, 160.0 / 255.0, 164.0 / 255.0],
&[],
1,
0.8,
);
let mask = vec![0u8, 1, 2, 5, 1, 0];
let rgba = mask_overlay_rgba(&mask, &lut);
assert_eq!(rgba.len(), mask.len());
assert_eq!(rgba[0], [0, 0, 0, 0]);
assert_eq!(rgba[5], [0, 0, 0, 0]);
assert_eq!(rgba[1], [160, 160, 164, 204]);
assert_eq!(rgba[4], [160, 160, 164, 204]);
assert_eq!(rgba[2], [160, 160, 164, 102]);
assert_eq!(rgba[3], [160, 160, 164, 102]);
for (px, &level) in rgba.iter().zip(mask.iter()) {
assert_eq!(*px, lut[level as usize]);
}
}
#[test]
fn set_mask_colors_and_transparency_feed_the_lut() {
let mut w = MaskToolsWidget::new(2, 2);
w.set_mask_colors([255, 0, 0], Some(3));
let overrides_f32: Vec<Option<[f32; 3]>> = w
.overrides
.iter()
.map(|c| {
c.map(|rgb| {
[
rgb[0] as f32 / 255.0,
rgb[1] as f32 / 255.0,
rgb[2] as f32 / 255.0,
]
})
})
.collect();
let lut = crate::core::colormap::mask_overlay_lut([0.5, 0.5, 0.5], &overrides_f32, 1, 0.8);
assert_eq!(&lut[3][0..3], &[255, 0, 0]);
w.set_transparency(2.0);
assert_eq!(w.alpha, 1.0);
w.set_mask_colors([0, 0, 255], None);
assert!(w.overrides.iter().all(|c| *c == Some([0, 0, 255])));
w.reset_mask_colors(Some(7));
assert_eq!(w.overrides[7], None);
assert_eq!(w.overrides[6], Some([0, 0, 255]));
w.reset_mask_colors(None);
assert!(w.overrides.iter().all(|c| c.is_none()));
}
#[test]
fn current_mask_color_reports_override_then_falls_back_to_base() {
let mut w = MaskToolsWidget::new(2, 2);
w.level = 4;
assert_eq!(w.current_mask_color(), w.color);
w.set_mask_colors([255, 0, 0], Some(4));
assert_eq!(w.current_mask_color(), Color32::from_rgb(255, 0, 0));
w.level = 5;
assert_eq!(w.current_mask_color(), w.color);
w.level = 4;
w.reset_mask_colors(Some(4));
assert_eq!(w.current_mask_color(), w.color);
}
#[test]
fn overlay_z_value_is_one_above_active_image() {
assert_eq!(overlay_z_value(Some(3.0)), 4.0);
assert_eq!(overlay_z_value(Some(-2.5)), -1.5);
assert_eq!(overlay_z_value(None), 1.0);
}
}