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};
use crate::widget::plot_widget::{PlotResponse, feed_draw_state};
#[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()
}
}
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,
history: MaskHistory,
mask_handle: Option<ItemHandle>,
is_dirty: bool,
last_pencil_pos: Option<(i64, i64)>,
shape_draw: Option<DrawState>,
}
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,
history,
mask_handle: None,
is_dirty: true, last_pencil_pos: None,
shape_draw: 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.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 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 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(|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");
ui.selectable_value(&mut self.active_tool, MaskTool::Eraser, "Eraser")
.on_hover_text("Erase mask");
ui.selectable_value(&mut self.active_tool, MaskTool::Rectangle, "Rectangle")
.on_hover_text("Mask a rectangular region");
ui.selectable_value(&mut self.active_tool, MaskTool::Polygon, "Polygon")
.on_hover_text("Mask a polygonal region");
ui.selectable_value(&mut self.active_tool, MaskTool::Ellipse, "Ellipse")
.on_hover_text("Mask an elliptical region");
ui.add(egui::Slider::new(&mut self.level, 1..=255).text("Mask level"));
if self.active_tool != MaskTool::None {
ui.add(egui::Slider::new(&mut self.brush_size, 1..=50).text("Brush size"));
}
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();
}
if ui.button("Invert").clicked() {
self.invert();
self.commit();
}
if ui.button("Clear").clicked() {
self.clear();
self.commit();
}
if ui.button("Clear All").clicked() {
self.clear_all();
self.commit();
}
});
}
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.active_tool == MaskTool::Pencil;
self.paint_pencil_point(data_y.floor() as i64, data_x.floor() as i64, 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;
}
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 {
self.fill_from_draw(params);
self.shape_draw = Some(DrawState::new(mode));
}
event
}
fn fill_from_draw(&mut self, params: &DrawParams) {
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, true);
}
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, true);
}
DrawParams::Polygon { vertices } => {
let cells = polygon_vertices_to_cells(vertices);
self.update_polygon(level, &cells, true);
}
_ => 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)?;
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 r in 0..copy_h {
let dst = r * self.width as usize;
let src = r * 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;
Ok(resized)
}
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)
}
}
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 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_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,
});
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 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 });
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),
});
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)],
});
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_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!("siplot_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 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 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);
}
}