diff --git a/CHANGELOG.md b/CHANGELOG.md index d4dfe13..f10a4fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## [X.X.X] - XXXX-XX-XX - Added traces' names and paths on the toolbar. +- Revamped the filter feature. ## [1.0.0] - 2025-10-15 diff --git a/Cargo.toml b/Cargo.toml index dceab75..a2e7c7b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,5 +17,4 @@ npyz = "0.8.3" ndarray = "0.16.1" fixed = "1.29.0" clap = { version = "4.5.46", features = ["derive", "wrap_help"] } -biquad = "0.5.0" -serde = "1.0.228" +sci-rs = "0.4.1" diff --git a/README.md b/README.md index 2f1899e..bb99f98 100644 --- a/README.md +++ b/README.md @@ -45,11 +45,7 @@ Multiple traces can be opened in horizontal split-screen, with their views optio turboplot waveform1.npy waveform2.npy ``` -Traces can be filtered with basic filters when they are loaded. Low-pass, high-pass, band-pass and notch filters are possible. This requires specifying the sampling rate (in MHz) and the cuttof frequency (in KHz). - -``` -cargo run --release -- -s 100 --filter low-pass --cutoff-freq 1000 waveform.npy -``` +Traces can be filtered at any time using an interactive filter designer. Click the "Filter" button in the toolbar to create and apply low-pass, high-pass, band-pass, or band-stop filters. You can adjust the filter type, cutoff frequencies, and order. The sampling rate (in MS/s) must be specified (with `-s` parameter or directly from the GUI) so frequency cutoffs are meaningful. Applied filters can be cleared with a single click to instantly return to the original trace. By default TurboPlot will spawn 1 GPU rendering thread and the maximum CPU rendering threads the hardware can run simultaneously. To fit your needs, this can be changed by specifying the number of threads for each type of rendering backend: diff --git a/src/filtering.rs b/src/filtering.rs index 04d2fe7..5eaefe6 100644 --- a/src/filtering.rs +++ b/src/filtering.rs @@ -1,46 +1,378 @@ -use biquad::{Biquad, Coefficients, DirectForm1, Hertz, Q_BUTTERWORTH_F32, Type}; -use serde::Serialize; - -#[derive(clap::ValueEnum, Copy, Clone, Debug, Serialize)] -#[serde(rename_all = "kebab-case")] -/// Digital filters supported by TurboPlot. -pub enum Filter { - /// Low-pass filter - LowPass, - /// High-pass filter - HighPass, - /// Band-Pass filter - BandPass, - /// Notch filter - Notch, +use sci_rs::signal::filter::design::{ + BesselThomsonNorm, DigitalFilter, FilterBandType, FilterOutputType, FilterType, iirfilter_dyn, +}; + +/// Wrapper for [`FilterType`] providing [`Clone`], [`PartialEq`] and [`std::fmt::Display`] traits for use in GUI selectors. +struct FilterTypeWrapper(FilterType); + +impl PartialEq for FilterTypeWrapper { + /// Checks equality by comparing the inner filter type. + fn eq(&self, other: &Self) -> bool { + matches!( + (&self.0, &other.0), + (FilterType::Butterworth, FilterType::Butterworth) + | (FilterType::ChebyshevI, FilterType::ChebyshevI) + | (FilterType::ChebyshevII, FilterType::ChebyshevII) + | (FilterType::CauerElliptic, FilterType::CauerElliptic) + | ( + FilterType::BesselThomson(BesselThomsonNorm::Delay), + FilterType::BesselThomson(BesselThomsonNorm::Delay) + ) + | ( + FilterType::BesselThomson(BesselThomsonNorm::Phase), + FilterType::BesselThomson(BesselThomsonNorm::Phase) + ) + | ( + FilterType::BesselThomson(BesselThomsonNorm::Mag), + FilterType::BesselThomson(BesselThomsonNorm::Mag) + ) + ) + } +} + +impl std::fmt::Display for FilterTypeWrapper { + /// Provides a user-friendly string for each filter type variant for display in GUI selectors. + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match &self.0 { + FilterType::Butterworth => write!(f, "Butterworth"), + FilterType::ChebyshevI => write!(f, "Chebyshev I"), + FilterType::ChebyshevII => write!(f, "Chebyshev II"), + FilterType::CauerElliptic => write!(f, "Cauer Elliptic"), + FilterType::BesselThomson(BesselThomsonNorm::Delay) + | FilterType::BesselThomson(BesselThomsonNorm::Phase) + | FilterType::BesselThomson(BesselThomsonNorm::Mag) => write!(f, "Bessel Thomson"), + } + } +} + +impl Clone for FilterTypeWrapper { + /// Clones the inner filter type. + fn clone(&self) -> Self { + let ft = match &self.0 { + FilterType::Butterworth => FilterType::Butterworth, + FilterType::ChebyshevI => FilterType::ChebyshevI, + FilterType::ChebyshevII => FilterType::ChebyshevII, + FilterType::CauerElliptic => FilterType::CauerElliptic, + FilterType::BesselThomson(n) => match n { + BesselThomsonNorm::Delay => FilterType::BesselThomson(BesselThomsonNorm::Delay), + BesselThomsonNorm::Phase => FilterType::BesselThomson(BesselThomsonNorm::Phase), + BesselThomsonNorm::Mag => FilterType::BesselThomson(BesselThomsonNorm::Mag), + }, + }; + FilterTypeWrapper(ft) + } +} + +/// Wrapper for [`FilterBandType`] providing [`PartialEq`] and [`std::fmt::Display`] traits for use in GUI selectors. +struct FilterBandTypeWrapper(FilterBandType); + +impl PartialEq for FilterBandTypeWrapper { + /// Checks equality by comparing the inner filter band type. + fn eq(&self, other: &Self) -> bool { + matches!( + (&self.0, &other.0), + (FilterBandType::Lowpass, FilterBandType::Lowpass) + | (FilterBandType::Highpass, FilterBandType::Highpass) + | (FilterBandType::Bandpass, FilterBandType::Bandpass) + | (FilterBandType::Bandstop, FilterBandType::Bandstop) + ) + } } -/// Converts CLI filters into biquad ones. -impl From for Type { - fn from(value: Filter) -> Self { - match value { - Filter::LowPass => Type::LowPass, - Filter::HighPass => Type::HighPass, - Filter::BandPass => Type::BandPass, - Filter::Notch => Type::Notch, +impl std::fmt::Display for FilterBandTypeWrapper { + /// Provides a user-friendly string for each filter band type variant for display in GUI selectors. + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match &self.0 { + FilterBandType::Lowpass => write!(f, "Low pass"), + FilterBandType::Highpass => write!(f, "High pass"), + FilterBandType::Bandpass => write!(f, "Band pass"), + FilterBandType::Bandstop => write!(f, "Band stop"), } } } -/// Define an interface to apply filter on traces. -pub trait Filtering { - fn apply_filter(&mut self, filter: Filter, fs: Hertz, f0: Hertz); +/// A struct that encapsulates filter design parameters and state for a filter design dialog. +/// +/// `FilterDesigner` provides a way to configure and manage settings for designing digital filters, +/// including filter type, band type, order, frequency specifications, and dialog state. +/// It also stores the last error encountered during filter design for user feedback. +pub struct FilterDesigner { + filter_band_type: FilterBandTypeWrapper, + filter_type: FilterTypeWrapper, + filter_order: u32, + filter_f1: f32, + filter_f2: f32, + filter_pass: f32, + filter_stop: f32, + is_open: bool, + last_error: Option, } -/// Extends Vec to support digital filters. -impl Filtering for Vec { - fn apply_filter(&mut self, filter: Filter, fs: Hertz, f0: Hertz) { - let coeffs = - Coefficients::::from_params(filter.into(), fs, f0, Q_BUTTERWORTH_F32).unwrap(); - let mut biquad = DirectForm1::::new(coeffs); +impl FilterDesigner { + pub fn new() -> Self { + Self { + filter_band_type: FilterBandTypeWrapper(FilterBandType::Lowpass), + filter_type: FilterTypeWrapper(FilterType::Butterworth), + filter_order: 4, + filter_f1: 0.0, + filter_f2: 0.0, + filter_pass: 0.5, + filter_stop: 60.0, + is_open: false, + last_error: None, + } + } + + pub fn open(&mut self) { + self.is_open = true; + } + + pub fn is_open(&self) -> bool { + self.is_open + } + + /// Displays a modal dialog for designing a digital filter + /// + /// Note: filters coefficients are normalized using the sampling rate, so the frequency values + /// should be in MHz. + /// + /// # Arguments + /// + /// * `ctx` - The egui context. + /// * `fs` - The sampling rate in MHz. + /// + /// # Returns + /// + /// * An `Option` containing the resulting `DigitalFilter` if the user clicks "Apply filter", + /// or `None` if the user clicks "Cancel" or closes the modal. + pub fn ui_design_filter(&mut self, ctx: &egui::Context, fs: f32) -> Option> { + if !self.is_open { + return None; + } + let mut result = None; + + let modal = egui::Modal::new(egui::Id::new("Create filter")); + modal.show(ctx, |ui| { + ui.heading("Filter Designer"); + ui.add_space(16.0); + ui.label(format!("Sampling rate: {} MS/s", fs)); + ui.add_space(8.0); + + egui::Grid::new("filter_grid").show(ui, |ui| { + ui.label("Filter type:"); + egui::ComboBox::from_id_salt(egui::Id::new("filter_band_type")) + .selected_text(self.filter_band_type.to_string()) + .show_ui(ui, |ui| { + ui.selectable_value( + &mut self.filter_band_type, + FilterBandTypeWrapper(FilterBandType::Lowpass), + "Low pass", + ); + ui.selectable_value( + &mut self.filter_band_type, + FilterBandTypeWrapper(FilterBandType::Highpass), + "High pass", + ); + ui.selectable_value( + &mut self.filter_band_type, + FilterBandTypeWrapper(FilterBandType::Bandpass), + "Band pass", + ); + ui.selectable_value( + &mut self.filter_band_type, + FilterBandTypeWrapper(FilterBandType::Bandstop), + "Band stop", + ); + }); + egui::ComboBox::from_id_salt(egui::Id::new("filter_type")) + .selected_text(self.filter_type.to_string()) + .show_ui(ui, |ui| { + ui.selectable_value( + &mut self.filter_type, + FilterTypeWrapper(FilterType::Butterworth), + "Butterworth", + ); + ui.selectable_value( + &mut self.filter_type, + FilterTypeWrapper(FilterType::ChebyshevI), + "Chebyshev I", + ); + ui.selectable_value( + &mut self.filter_type, + FilterTypeWrapper(FilterType::ChebyshevII), + "Chebyshev II", + ); + }); + ui.end_row(); + + ui.label("Order:"); + ui.add( + egui::DragValue::new(&mut self.filter_order) + .range(0..=16) + .speed(0.05), + ); + ui.end_row(); + + match &self.filter_band_type.0 { + FilterBandType::Lowpass | FilterBandType::Highpass => { + ui.label("Freq:"); + ui.add( + egui::DragValue::new(&mut self.filter_f1) + .range(0.0..=fs / 2.0f32) + .speed(1.0) + .suffix(" MHz"), + ); + } + FilterBandType::Bandpass | FilterBandType::Bandstop => { + ui.label("Freq1:"); + ui.add( + egui::DragValue::new(&mut self.filter_f1) + .range(0.0..=fs / 2.0f32) + .speed(1.0) + .suffix(" MHz"), + ); + ui.label("Freq2:"); + ui.add( + egui::DragValue::new(&mut self.filter_f2) + .range(0.0..=fs / 2.0f32) + .speed(1.0) + .suffix(" MHz"), + ); + } + } + ui.end_row(); + + if self.filter_type == FilterTypeWrapper(FilterType::ChebyshevI) + || self.filter_type == FilterTypeWrapper(FilterType::CauerElliptic) + { + ui.label("Pass:"); + ui.add( + egui::DragValue::new(&mut self.filter_pass) + .range(0.0..=1.0) + .speed(0.005) + .suffix(" dB"), + ); + } - for x in self.iter_mut() { - *x = biquad.run(*x); + if self.filter_type == FilterTypeWrapper(FilterType::ChebyshevII) + || self.filter_type == FilterTypeWrapper(FilterType::CauerElliptic) + { + ui.label("Stop:"); + ui.add( + egui::DragValue::new(&mut self.filter_stop) + .range(0.0..=100.0) + .speed(0.2) + .suffix(" dB"), + ); + } + ui.end_row(); + }); + + if let Some(err) = &self.last_error { + ui.add_space(6.0); + ui.colored_label(egui::Color32::RED, err); + } + + ui.add_space(4.0); + ui.separator(); + + egui::Sides::new().show( + ui, + |_ui| {}, + |ui| { + if ui.button(" Apply ").clicked() { + match self.build_filter(fs) { + Ok(f) => { + self.last_error = None; + result = Some(f); + self.is_open = false; + } + Err(msg) => { + self.last_error = Some(msg.to_string()); + } + } + } + if ui.button(" Cancel ").clicked() { + self.last_error = None; + self.is_open = false; + } + }, + ); + }); + + result + } + + /// Verifies and builds a digital filter based on the current filter design parameters. + /// + /// # Arguments + /// + /// * `fs` - The sampling rate in MHz. + /// + /// # Returns + /// + /// * A `Result` containing the resulting `DigitalFilter` if the filter is valid, + /// or an error message if the filter is invalid. + fn build_filter<'a>(&self, fs: f32) -> Result, &'a str> { + if self.filter_order == 0 { + return Err("Order must be >= 1"); } + + // Nyquist frequency verification. + let wn: Vec = match &self.filter_band_type.0 { + FilterBandType::Lowpass => { + if !(self.filter_f1 > 0.0 && self.filter_f1 < fs / 2.0) { + return Err("F1 must be in ]0, fs/2[ interval"); + } + vec![self.filter_f1] + } + FilterBandType::Highpass => { + if !(self.filter_f1 > 0.0 && self.filter_f1 < fs / 2.0) { + return Err("F1 must be in ]0, fs/2[ interval"); + } + vec![self.filter_f1] + } + FilterBandType::Bandpass | FilterBandType::Bandstop => { + let f0 = self.filter_f1.min(self.filter_f2); + let f1 = self.filter_f1.max(self.filter_f2); + if !(f0 > 0.0 && f1 < fs / 2.0 && f0 < f1) { + return Err("F1 and F2 must be in ]0, fs/2[ interval"); + } + vec![f0, f1] + } + }; + + // Pass and stop ripple verification depending on the filter type. + match &self.filter_type.0 { + FilterType::ChebyshevI => { + if self.filter_pass <= 0.0 { + return Err("Pass ripple must be > 0 dB for Chebyshev I"); + } + } + FilterType::ChebyshevII => { + if self.filter_stop <= 0.0 { + return Err("Stop attenuation must be > 0 dB for Chebyshev II"); + } + } + FilterType::CauerElliptic => { + if self.filter_pass <= 0.0 || self.filter_stop <= 0.0 { + return Err("Pass ripple and Stop attenuation must be > 0 dB for Elliptic"); + } + } + FilterType::Butterworth | FilterType::BesselThomson(_) => {} + }; + + // iirfilter takes Wn and fs in the same units; we keep MHz across UI and fs. + Ok(iirfilter_dyn::( + self.filter_order as usize, + wn, + Some(self.filter_pass), + Some(self.filter_stop), + Some(self.filter_band_type.0), + Some(self.filter_type.clone().0), + Some(false), + Some(FilterOutputType::Sos), + Some(fs), + )) } } diff --git a/src/main.rs b/src/main.rs index 6f7e893..b38a0f9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,18 +1,16 @@ use crate::{ - filtering::Filtering, loaders::{TraceFormat, guess_format, load_csv, load_npy}, multi_viewer::MultiViewer, renderer::{CpuRenderer, GpuRenderer, Renderer}, tiling::{Tiling, TilingRenderer}, }; -use biquad::ToHertz; use clap::Parser; use eframe::egui; use egui::Vec2; use std::{ fs::File, io::BufReader, - sync::{Arc, Condvar, Mutex}, + sync::{Arc, Condvar, Mutex, RwLock}, thread::{self, available_parallelism}, }; @@ -35,12 +33,6 @@ struct Args { /// Trace sampling rate in MS/s. Default to 100MS/s #[arg(long, short, default_value_t = 100.0f32)] sampling_rate: f32, - /// Specify a digital filter. - #[arg(long, requires("cutoff_freq"), value_enum)] - filter: Option, - /// Cutoff frequency in kHz if a filter has been specified. - #[arg(long, requires("filter"))] - cutoff_freq: Option, /// Trace file format. If not specified, TurboPlot will guess from file extension. #[arg(long, short)] format: Option, @@ -73,31 +65,28 @@ fn main() { let file = File::open(path).expect("Failed to open file"); let buf_reader = BufReader::new(file); - let mut trace = match format { + let trace = match format { TraceFormat::Numpy => load_npy(buf_reader), TraceFormat::Csv => load_csv(buf_reader, args.skip_lines, args.column), }; - if let Some(filter) = args.filter { - trace.apply_filter( - filter, - args.sampling_rate.mhz(), - args.cutoff_freq.unwrap().khz(), - ) - } - traces.push(trace); } let shared_tiling = Arc::new((Mutex::new(Tiling::new()), Condvar::new())); - let traces = Arc::new(traces); + let traces: Arc>>>> = Arc::new( + traces + .into_iter() + .map(|t| RwLock::new(Arc::new(t))) + .collect(), + ); for _ in 0..args.gpu { let shared_tiling_clone = shared_tiling.clone(); let trace_clone = traces.clone(); thread::spawn(move || { let renderer: Box = Box::new(GpuRenderer::new()); - TilingRenderer::new(shared_tiling_clone, &trace_clone, renderer).render_loop(); + TilingRenderer::new(shared_tiling_clone, trace_clone, renderer).render_loop(); }); } @@ -121,7 +110,7 @@ fn main() { let trace_clone = traces.clone(); thread::spawn(move || { let renderer: Box = Box::new(CpuRenderer::new()); - TilingRenderer::new(shared_tiling_clone, &trace_clone, renderer).render_loop(); + TilingRenderer::new(shared_tiling_clone, trace_clone, renderer).render_loop(); }); } @@ -139,7 +128,7 @@ fn main() { &_cc.egui_ctx, shared_tiling, &args.paths, - &traces, + traces, args.sampling_rate, ))) }), diff --git a/src/multi_viewer.rs b/src/multi_viewer.rs index 3437995..36a5dc7 100644 --- a/src/multi_viewer.rs +++ b/src/multi_viewer.rs @@ -1,6 +1,6 @@ use crate::{sync_features::SyncFeatures, tiling::Tiling, viewer::Viewer}; use egui::{Rect, pos2}; -use std::sync::{Arc, Condvar, Mutex}; +use std::sync::{Arc, Condvar, Mutex, RwLock}; /// Split window space to display multiple traces using multiple [`Viewer`]. When enabled, /// synchronizes the camera of the different viewers. @@ -16,21 +16,20 @@ impl<'a> MultiViewer<'a> { ctx: &egui::Context, shared_tiling: Arc<(Mutex, Condvar)>, paths: &'a [String], - traces: &'a [Vec], + traces: Arc>>>>, sampling_rate: f32, ) -> Self { Self { viewers: paths .iter() - .zip(traces.iter()) .enumerate() - .map(|(i, t)| { + .map(|(i, p)| { Viewer::new( i as u32, ctx, shared_tiling.clone(), - t.0, - t.1, + p, + traces.clone(), sampling_rate, ) }) diff --git a/src/tiling.rs b/src/tiling.rs index 04e8128..31cedb0 100644 --- a/src/tiling.rs +++ b/src/tiling.rs @@ -3,12 +3,12 @@ use crate::{ util::{Fixed, FixedVec2}, }; use egui::{Color32, ColorImage, epaint::Hsva, lerp}; -use std::sync::{Arc, Condvar, Mutex}; +use std::sync::{Arc, Condvar, Mutex, RwLock}; /// A library of tiles and their current rendering status and result. /// /// This structure is shared between the viewer, which asks for tiles and use them, and a tile -/// rendered which receives and fulfill rendering requests. +/// renderer which receives and fulfill rendering requests. pub struct Tiling { pub tiles: Vec, } @@ -122,16 +122,16 @@ pub struct TileProperties { pub size: TileSize, } -pub struct TilingRenderer<'a> { +pub struct TilingRenderer { renderer: Box, shared_tiling: Arc<(Mutex, Condvar)>, - traces: &'a Vec>, + traces: Arc>>>>, } -impl<'a> TilingRenderer<'a> { +impl TilingRenderer { pub fn new( shared_tiling: Arc<(Mutex, Condvar)>, - traces: &'a Vec>, + traces: Arc>>>>, renderer: Box, ) -> Self { Self { @@ -189,8 +189,9 @@ impl<'a> TilingRenderer<'a> { scale: FixedVec2, size: TileSize, ) -> Vec { - let trace: &Vec = &self.traces[id as usize]; - let trace_len = trace.len() as i32; + // Snapshot the current trace for this viewer id + let trace_arc = self.traces[id as usize].read().unwrap().clone(); + let trace_len = trace_arc.len() as i32; let i_start = (index as f32 * size.w as f32 * scale.x.to_num::()).floor() as i32; let i_end = ((index + 1) as f32 * size.w as f32 * scale.x.to_num::()).floor() as i32; @@ -198,7 +199,7 @@ impl<'a> TilingRenderer<'a> { return vec![0; size.area() as usize]; } - let trace_chunk = &trace[i_start as usize..(i_end + 1).min(trace_len) as usize]; + let trace_chunk = &trace_arc[i_start as usize..(i_end + 1).min(trace_len) as usize]; // We need at least 2 points to have one segment. if trace_chunk.len() < 2 { diff --git a/src/viewer.rs b/src/viewer.rs index 64fc8ab..9dd44b9 100644 --- a/src/viewer.rs +++ b/src/viewer.rs @@ -1,5 +1,6 @@ use crate::{ camera::Camera, + filtering, renderer::RENDERER_MAX_TRACE_SIZE, sync_features::SyncFeatures, tiling::{ColorScale, Gradient, TileProperties, TileSize, TileStatus, Tiling}, @@ -10,11 +11,12 @@ use egui::{ PopupCloseBehavior, Rect, Sense, Shape, Stroke, TextFormat, TextureHandle, TextureOptions, Ui, pos2, text::LayoutJob, vec2, }; +use sci_rs::signal::filter::{design::DigitalFilter, sosfiltfilt_dyn}; use std::{ collections::HashMap, ops::Add, path::Path, - sync::{Arc, Condvar, Mutex}, + sync::{Arc, Condvar, Mutex, RwLock}, }; /// Defines the width of the tiles rendered by the GPU. @@ -35,10 +37,10 @@ pub struct Viewer<'a> { /// Viewer identifier used to distinguish tiles in the shared tiling in case there are multiple /// viewers. id: u32, - /// The trace being displayed. - trace: &'a Vec, /// The trace's path. path: &'a String, + /// All traces shared across viewers and renderers. + traces: Arc>>>>, /// Current camera settings. camera: Camera, /// Rendering tiles shared between the user interface and the GPU tiles renderer. @@ -63,10 +65,18 @@ pub struct Viewer<'a> { /// Trace min and max values. /// Used for autoscaling. trace_min_max: [f32; 2], + /// Original trace snapshot used to restore when clearing filter. + original_trace: Option>>, /// When true, the viewer will change scale and offset so the trace fits the screen. autoscale_request: bool, /// Trace sampling rate in MS/s sampling_rate: f32, + /// Filter used to generate the filtered trace. + filter: Option>, + /// When true, the viewer will open the filter designer modal or clear the filter. + filter_request: bool, + /// Filter designer modal. + filter_designer: filtering::FilterDesigner, } impl<'a> Viewer<'a> { @@ -77,21 +87,9 @@ impl<'a> Viewer<'a> { ctx: &egui::Context, shared_tiling: Arc<(Mutex, Condvar)>, path: &'a String, - trace: &'a Vec, + traces: Arc>>>>, sampling_rate: f32, ) -> Self { - let trace_min_max = [ - trace - .iter() - .cloned() - .min_by(f32::total_cmp) - .expect("Trace has NaN sample"), - trace - .iter() - .cloned() - .max_by(f32::total_cmp) - .expect("Trace has NaN sample"), - ]; let color_scale = ColorScale { power: 1.0, opacity: 10.0, @@ -100,10 +98,11 @@ impl<'a> Viewer<'a> { end: Color32::WHITE, }, }; + let trace_min_max = Self::compute_min_max(&traces[id as usize].read().unwrap().clone()); Self { id, - trace, path, + traces, camera: Camera::new(), shared_tiling, tool: Tool::Move, @@ -114,8 +113,12 @@ impl<'a> Viewer<'a> { textures: HashMap::default(), texture_checkboard: generate_checkboard(ctx, 64), trace_min_max, + original_trace: None, autoscale_request: true, sampling_rate, + filter: None, + filter_request: false, + filter_designer: filtering::FilterDesigner::new(), } } @@ -129,10 +132,28 @@ impl<'a> Viewer<'a> { } } + /// Compute the min and max values of a trace. + /// Panics if the trace has NaN samples. + fn compute_min_max(trace: &[f32]) -> [f32; 2] { + [ + trace + .iter() + .cloned() + .min_by(f32::total_cmp) + .expect("Trace has NaN sample"), + trace + .iter() + .cloned() + .max_by(f32::total_cmp) + .expect("Trace has NaN sample"), + ] + } + /// Toolbar widgets rendering. pub fn ui_toolbar(&mut self, ui: &mut Ui, sync_options: Option<&mut SyncFeatures>) { ui.horizontal(|ui| { - ui.label(format!("Trace: {}S", format_number_unit(self.trace.len()))); + let trace_arc = self.traces[self.id as usize].read().unwrap().clone(); + ui.label(format!("Trace: {}S", format_number_unit(trace_arc.len()))); ui.label("@"); let drag = DragValue::new(&mut self.sampling_rate) @@ -206,6 +227,16 @@ impl<'a> Viewer<'a> { self.tool_step = 0; } + if ui + .button(match self.filter { + Some(_) => "Clear filter", + None => "Filter", + }) + .clicked() + { + self.filter_request = true; + } + if let Some(options) = sync_options { let response = ui.button("Sync"); Popup::menu(&response) @@ -262,6 +293,33 @@ impl<'a> Viewer<'a> { ) }); + // Filter request management. + // If the filter button has been clicked, we either: + // - Clear the filter and restore original trace if available (if a filter has been applied) + // - Open the filter designer modal to design a new filter and apply it if the user clicks OK + if self.filter_request { + match self.filter { + Some(_) => { + // Clear filter: restore original trace if available + self.clear_filter(); + self.filter_request = false; + } + None => { + self.filter_designer.open(); + self.filter = self + .filter_designer + .ui_design_filter(ctx, self.sampling_rate); + if !self.filter_designer.is_open() { + // Apply filter if one has been built + if self.filter.is_some() { + self.apply_filter(); + } + self.filter_request = false; + } + } + } + } + let response = ui.allocate_rect(viewport, Sense::drag()); // use hovered to disable interaction when cursor is on another widget (toolbar or other @@ -378,7 +436,8 @@ impl<'a> Viewer<'a> { if self.autoscale_request { self.autoscale_request = false; - let trace_len = Fixed::from_num(self.trace.len()); + let trace_arc = self.traces[self.id as usize].read().unwrap().clone(); + let trace_len = Fixed::from_num(trace_arc.len()); self.camera.scale.x = (trace_len / Fixed::from_num(viewport.width() * ppp)) .min(Fixed::from_num(MIN_SCALE_X)); self.camera.shift.x = trace_len / 2; @@ -397,6 +456,47 @@ impl<'a> Viewer<'a> { } } + /// Apply the filter to the current trace. + fn apply_filter(&mut self) { + let Some(DigitalFilter::Sos(ref s)) = self.filter else { + panic!("No valid SOS filter is set."); + }; + let current_trace = self.traces[self.id as usize].read().unwrap().clone(); + + self.original_trace = Some(current_trace.clone()); + let filtered: Vec = sosfiltfilt_dyn(current_trace.iter(), &s.sos); + + self.trace_min_max = Self::compute_min_max(&filtered); + + let mut w = self.traces[self.id as usize].write().unwrap(); + *w = Arc::new(filtered); + drop(w); + + self.invalidate_tiles_and_textures(); + } + + /// Clear the filter and restore original trace. + fn clear_filter(&mut self) { + let original = self.original_trace.take().unwrap(); // Panic if no original trace is available. + + self.trace_min_max = Self::compute_min_max(&original); // Recompute min and max values for the original trace. + + let mut lock = self.traces[self.id as usize].write().unwrap(); + *lock = original; + drop(lock); + + self.invalidate_tiles_and_textures(); + self.filter.take(); + } + + /// Invalidate all tiles and textures belonging to this viewer. + fn invalidate_tiles_and_textures(&mut self) { + let mut tiling = self.shared_tiling.0.lock().unwrap(); + tiling.tiles.retain(|t| t.properties.id != self.id); + self.shared_tiling.1.notify_all(); + self.textures.clear(); + } + pub fn paint_toolbar( &mut self, ctx: &egui::Context, @@ -516,26 +616,27 @@ impl<'a> Viewer<'a> { /// Paint the waveform as lines using egui painter. This is more suited for high zoom values /// and benefits from lines antialiasing. fn paint_waveform_as_lines(&self, ppp: f32, painter: &Painter, viewport: &Rect) { + let trace_arc = self.traces[self.id as usize].read().unwrap().clone(); let t0 = self .camera .screen_to_world_x(viewport, ppp, 0.0) .floor() .to_num::() - .clamp(0, self.trace.len() as isize) as usize; + .clamp(0, trace_arc.len() as isize) as usize; let t1 = self .camera .screen_to_world_x(viewport, ppp, viewport.max.x) .ceil() .to_num::() .add(1) - .clamp(0, self.trace.len() as isize) as usize; + .clamp(0, trace_arc.len() as isize) as usize; let points = (t0..t1) .map(|t| { let x = self .camera .world_to_screen_x(viewport, ppp, Fixed::from_num(t)); let y = viewport.center().y - - (self.trace[t] + self.camera.shift.y.to_num::()) + - (trace_arc[t] + self.camera.shift.y.to_num::()) * self.camera.scale.y.to_num::() / ppp; pos2(x, y)