From 26135bce7102496b3343202ea68bced09d37ed22 Mon Sep 17 00:00:00 2001 From: Baptistin BOILOT Date: Wed, 22 Oct 2025 10:53:30 +0200 Subject: [PATCH 1/8] Add filter designer UI --- src/filtering.rs | 2 +- src/viewer.rs | 111 +++++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 104 insertions(+), 9 deletions(-) diff --git a/src/filtering.rs b/src/filtering.rs index 04d2fe7..aa40ff9 100644 --- a/src/filtering.rs +++ b/src/filtering.rs @@ -1,7 +1,7 @@ use biquad::{Biquad, Coefficients, DirectForm1, Hertz, Q_BUTTERWORTH_F32, Type}; use serde::Serialize; -#[derive(clap::ValueEnum, Copy, Clone, Debug, Serialize)] +#[derive(clap::ValueEnum, Copy, Clone, Debug, Serialize, PartialEq, Eq)] #[serde(rename_all = "kebab-case")] /// Digital filters supported by TurboPlot. pub enum Filter { diff --git a/src/viewer.rs b/src/viewer.rs index 64fc8ab..e5c1707 100644 --- a/src/viewer.rs +++ b/src/viewer.rs @@ -1,14 +1,8 @@ use crate::{ - camera::Camera, - renderer::RENDERER_MAX_TRACE_SIZE, - sync_features::SyncFeatures, - tiling::{ColorScale, Gradient, TileProperties, TileSize, TileStatus, Tiling}, - util::{Fixed, format_f64_unit, format_number_unit, generate_checkboard}, + camera::Camera, filtering, renderer::RENDERER_MAX_TRACE_SIZE, sync_features::SyncFeatures, tiling::{ColorScale, Gradient, TileProperties, TileSize, TileStatus, Tiling}, util::{format_f64_unit, format_number_unit, generate_checkboard, Fixed} }; use egui::{ - Align, Align2, Color32, DragValue, FontFamily, Key, Painter, PointerButton, Popup, - PopupCloseBehavior, Rect, Sense, Shape, Stroke, TextFormat, TextureHandle, TextureOptions, Ui, - pos2, text::LayoutJob, vec2, + pos2, text::LayoutJob, vec2, Align, Align2, Color32, DragValue, FontFamily, Key, Painter, PointerButton, Popup, PopupCloseBehavior, Rect, Sense, Shape, Stroke, TextFormat, TextureHandle, TextureOptions, Ui }; use std::{ collections::HashMap, @@ -67,6 +61,17 @@ pub struct Viewer<'a> { autoscale_request: bool, /// Trace sampling rate in MS/s sampling_rate: f32, + filter: bool, + filter_modal_open: bool, + filter_band_type: filtering::Filter, + filter_type: filtering::Filter, + filter_order: u32, + filter_f1: f32, + filter_f2: f32, + filter_f3: f32, + filter_f4: f32, + filter_pass: f32, + filter_stop: f32, } impl<'a> Viewer<'a> { @@ -116,6 +121,17 @@ impl<'a> Viewer<'a> { trace_min_max, autoscale_request: true, sampling_rate, + filter: false, + filter_modal_open: false, + filter_band_type: filtering::Filter::LowPass, + filter_type: filtering::Filter::LowPass, + filter_order: 1, + filter_f1: 0.0, + filter_f2: 0.0, + filter_f3: 0.0, + filter_f4: 0.0, + filter_pass: 0.0, + filter_stop: 0.0, } } @@ -206,6 +222,20 @@ impl<'a> Viewer<'a> { self.tool_step = 0; } + match self.filter { + true => { + if ui.button("Clear filter").clicked() { + self.filter = false; + } + }, + false => { + if ui.button("Create filter").clicked() { + self.filter_modal_open = true; + self.filter = true; + } + } + } + if let Some(options) = sync_options { let response = ui.button("Sync"); Popup::menu(&response) @@ -262,6 +292,71 @@ impl<'a> Viewer<'a> { ) }); + if self.filter_modal_open { + egui::Modal::new(egui::Id::new("Create filter")).show(ctx, |ui| { + ui.heading("Filter Designer"); + ui.add_space(16.0); + ui.label(format!("Sampling rate: {} MHz", self.sampling_rate)); + 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")).show_ui(ui, |ui| { + ui.selectable_value(&mut self.filter_band_type, filtering::Filter::LowPass, "Low pass"); + ui.selectable_value(&mut self.filter_band_type, filtering::Filter::HighPass, "High pass"); + ui.selectable_value(&mut self.filter_band_type, filtering::Filter::BandPass, "Band pass"); + ui.selectable_value(&mut self.filter_band_type, filtering::Filter::Notch, "Notch"); + }); + egui::ComboBox::from_id_salt(egui::Id::new("filter_type")).show_ui(ui, |ui| { + ui.selectable_value(&mut self.filter_type, filtering::Filter::LowPass, "Low pass"); + ui.selectable_value(&mut self.filter_type, filtering::Filter::HighPass, "High pass"); + ui.selectable_value(&mut self.filter_type, filtering::Filter::BandPass, "Band pass"); + ui.selectable_value(&mut self.filter_type, filtering::Filter::Notch, "Notch"); + }); + ui.end_row(); + + ui.label("Order:"); + let drag = egui::DragValue::new(&mut self.filter_order).range(1..=100).speed(1.0); + ui.add(drag); + ui.end_row(); + + ui.label("F1:"); + let drag = egui::DragValue::new(&mut self.filter_f1).range(0.0..=10000.0).speed(1.0).suffix(" MHz"); + ui.add(drag); + ui.label("F2:"); + let drag = egui::DragValue::new(&mut self.filter_f2).range(0.0..=10000.0).speed(1.0).suffix(" MHz"); + ui.add(drag); + ui.end_row(); + ui.label("F3:"); + let drag = egui::DragValue::new(&mut self.filter_f3).range(0.0..=10000.0).speed(1.0).suffix(" MHz"); + ui.add(drag); + ui.label("F4:"); + let drag = egui::DragValue::new(&mut self.filter_f4).range(0.0..=10000.0).speed(1.0).suffix(" MHz"); + ui.add(drag); + ui.end_row(); + + ui.label("Pass:"); + let drag = egui::DragValue::new(&mut self.filter_pass).range(0.0..=100.0).speed(0.2).suffix(" dB"); + ui.add(drag); + ui.label("Stop:"); + let drag = egui::DragValue::new(&mut self.filter_stop).range(0.0..=100.0).speed(0.2).suffix(" dB"); + ui.add(drag); + ui.end_row(); + }); + ui.add_space(4.0); + ui.separator(); + ui.add_space(4.0); + egui::Sides::new().show(ui, |_ui| {},|ui| { + if ui.button(egui::RichText::new(" Cancel ").color(Color32::RED)).clicked() { + self.filter_modal_open = false; + } + if ui.button(egui::RichText::new(" Apply filter ").color(Color32::GREEN)).clicked() { + self.filter_modal_open = false; + } + }); + }); + } + let response = ui.allocate_rect(viewport, Sense::drag()); // use hovered to disable interaction when cursor is on another widget (toolbar or other From 6fc59132a36ca4acfcf599a3873e702602edb87d Mon Sep 17 00:00:00 2001 From: Baptistin BOILOT Date: Wed, 22 Oct 2025 13:23:08 +0200 Subject: [PATCH 2/8] Switch to sci-rs and remove filtering related cli args --- Cargo.toml | 3 +- src/filtering.rs | 77 ++++++++++++++---------- src/main.rs | 18 +----- src/viewer.rs | 150 +++++++++++++++++++++++++++++++++++------------ 4 files changed, 161 insertions(+), 87 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index dceab75..76feff8 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 = { git = "https://github.com/bboilot-ledger/sci-rs.git", branch = "filter_type_partial_eq" } diff --git a/src/filtering.rs b/src/filtering.rs index aa40ff9..b7c8af6 100644 --- a/src/filtering.rs +++ b/src/filtering.rs @@ -1,46 +1,59 @@ -use biquad::{Biquad, Coefficients, DirectForm1, Hertz, Q_BUTTERWORTH_F32, Type}; -use serde::Serialize; +use sci_rs::signal::filter::{ + design::{BesselThomsonNorm, DigitalFilter, FilterBandType, FilterType}, + sosfiltfilt_dyn, +}; -#[derive(clap::ValueEnum, Copy, Clone, Debug, Serialize, PartialEq, Eq)] -#[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, +/// Returns the display name for a given `FilterType`. +/// +/// # Arguments +/// +/// * `filter_type` - A `FilterType` enum variant representing the type of filter. +/// +/// # Returns +/// +/// * A string slice representing the name of the filter type. +pub fn filter_type_name<'a>(filter_type: FilterType) -> &'a str { + match filter_type { + FilterType::Butterworth => "Butterworth", + FilterType::ChebyshevI => "Chebyshev I", + FilterType::ChebyshevII => "Chebyshev II", + FilterType::CauerElliptic => "Cauer Elliptic", + FilterType::BesselThomson(BesselThomsonNorm::Delay) => "Bessel Thomson", + FilterType::BesselThomson(BesselThomsonNorm::Phase) => "Bessel Thomson", + FilterType::BesselThomson(BesselThomsonNorm::Mag) => "Bessel Thomson", + } } -/// 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, - } +/// Returns the display name for a given `FilterBandType`. +/// +/// # Arguments +/// +/// * `filter_band_type` - A `FilterBandType` enum variant representing the filter band type. +/// +/// # Returns +/// +/// * A string slice representing the name of the filter band type. +pub fn filter_band_type_name<'a>(filter_band_type: FilterBandType) -> &'a str { + match filter_band_type { + FilterBandType::Lowpass => "Low pass", + FilterBandType::Highpass => "High pass", + FilterBandType::Bandpass => "Band pass", + FilterBandType::Bandstop => "Band stop", } } /// Define an interface to apply filter on traces. pub trait Filtering { - fn apply_filter(&mut self, filter: Filter, fs: Hertz, f0: Hertz); + fn apply_filter(&mut self, filter: DigitalFilter); } /// 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); - - for x in self.iter_mut() { - *x = biquad.run(*x); - } + fn apply_filter(&mut self, filter: DigitalFilter) { + let DigitalFilter::Sos(sos) = filter else { + panic!("Not SOS filter") + }; + let filtered: Vec = sosfiltfilt_dyn(self.iter(), &sos.sos); + *self = filtered; } } diff --git a/src/main.rs b/src/main.rs index 6f7e893..6d86af0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,11 +1,9 @@ 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; @@ -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,19 +65,11 @@ 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); } diff --git a/src/viewer.rs b/src/viewer.rs index e5c1707..56bd4c0 100644 --- a/src/viewer.rs +++ b/src/viewer.rs @@ -1,9 +1,17 @@ use crate::{ - camera::Camera, filtering, renderer::RENDERER_MAX_TRACE_SIZE, sync_features::SyncFeatures, tiling::{ColorScale, Gradient, TileProperties, TileSize, TileStatus, Tiling}, util::{format_f64_unit, format_number_unit, generate_checkboard, Fixed} + camera::Camera, + filtering, + renderer::RENDERER_MAX_TRACE_SIZE, + sync_features::SyncFeatures, + tiling::{ColorScale, Gradient, TileProperties, TileSize, TileStatus, Tiling}, + util::{Fixed, format_f64_unit, format_number_unit, generate_checkboard}, }; use egui::{ - pos2, text::LayoutJob, vec2, Align, Align2, Color32, DragValue, FontFamily, Key, Painter, PointerButton, Popup, PopupCloseBehavior, Rect, Sense, Shape, Stroke, TextFormat, TextureHandle, TextureOptions, Ui + Align, Align2, Color32, DragValue, FontFamily, Key, Painter, PointerButton, Popup, + PopupCloseBehavior, Rect, Sense, Shape, Stroke, TextFormat, TextureHandle, TextureOptions, Ui, + pos2, text::LayoutJob, vec2, }; +use sci_rs::signal::filter::design::{BesselThomsonNorm, FilterBandType, FilterType}; use std::{ collections::HashMap, ops::Add, @@ -63,8 +71,8 @@ pub struct Viewer<'a> { sampling_rate: f32, filter: bool, filter_modal_open: bool, - filter_band_type: filtering::Filter, - filter_type: filtering::Filter, + filter_band_type: FilterBandType, + filter_type: FilterType, filter_order: u32, filter_f1: f32, filter_f2: f32, @@ -123,8 +131,8 @@ impl<'a> Viewer<'a> { sampling_rate, filter: false, filter_modal_open: false, - filter_band_type: filtering::Filter::LowPass, - filter_type: filtering::Filter::LowPass, + filter_band_type: FilterBandType::Lowpass, + filter_type: FilterType::Butterworth, filter_order: 1, filter_f1: 0.0, filter_f2: 0.0, @@ -227,7 +235,7 @@ impl<'a> Viewer<'a> { if ui.button("Clear filter").clicked() { self.filter = false; } - }, + } false => { if ui.button("Create filter").clicked() { self.filter_modal_open = true; @@ -298,62 +306,132 @@ impl<'a> Viewer<'a> { ui.add_space(16.0); ui.label(format!("Sampling rate: {} MHz", self.sampling_rate)); ui.add_space(8.0); - egui::Grid::new("filter_grid") - .show(ui, |ui| { + egui::Grid::new("filter_grid").show(ui, |ui| { ui.label("Filter type:"); - egui::ComboBox::from_id_salt(egui::Id::new("filter_band_type")).show_ui(ui, |ui| { - ui.selectable_value(&mut self.filter_band_type, filtering::Filter::LowPass, "Low pass"); - ui.selectable_value(&mut self.filter_band_type, filtering::Filter::HighPass, "High pass"); - ui.selectable_value(&mut self.filter_band_type, filtering::Filter::BandPass, "Band pass"); - ui.selectable_value(&mut self.filter_band_type, filtering::Filter::Notch, "Notch"); - }); - egui::ComboBox::from_id_salt(egui::Id::new("filter_type")).show_ui(ui, |ui| { - ui.selectable_value(&mut self.filter_type, filtering::Filter::LowPass, "Low pass"); - ui.selectable_value(&mut self.filter_type, filtering::Filter::HighPass, "High pass"); - ui.selectable_value(&mut self.filter_type, filtering::Filter::BandPass, "Band pass"); - ui.selectable_value(&mut self.filter_type, filtering::Filter::Notch, "Notch"); - }); + egui::ComboBox::from_id_salt(egui::Id::new("filter_band_type")) + .selected_text(filtering::filter_band_type_name(self.filter_band_type)) + .show_ui(ui, |ui| { + ui.selectable_value( + &mut self.filter_band_type, + FilterBandType::Lowpass, + "Low pass", + ); + ui.selectable_value( + &mut self.filter_band_type, + FilterBandType::Highpass, + "High pass", + ); + ui.selectable_value( + &mut self.filter_band_type, + FilterBandType::Bandpass, + "Band pass", + ); + ui.selectable_value( + &mut self.filter_band_type, + FilterBandType::Bandstop, + "Band stop", + ); + }); + egui::ComboBox::from_id_salt(egui::Id::new("filter_type")) + .selected_text(filtering::filter_type_name(self.filter_type)) + .show_ui(ui, |ui| { + ui.selectable_value( + &mut self.filter_type, + FilterType::Butterworth, + "Butterworth", + ); + ui.selectable_value( + &mut self.filter_type, + FilterType::ChebyshevI, + "Chebyshev I", + ); + ui.selectable_value( + &mut self.filter_type, + FilterType::ChebyshevII, + "Chebyshev II", + ); + ui.selectable_value( + &mut self.filter_type, + FilterType::CauerElliptic, + "Cauer Elliptic", + ); + ui.selectable_value( + &mut self.filter_type, + FilterType::BesselThomson(BesselThomsonNorm::Delay), + "Bessel Thomson", + ); + }); ui.end_row(); ui.label("Order:"); - let drag = egui::DragValue::new(&mut self.filter_order).range(1..=100).speed(1.0); + let drag = egui::DragValue::new(&mut self.filter_order) + .range(1..=100) + .speed(1.0); ui.add(drag); ui.end_row(); ui.label("F1:"); - let drag = egui::DragValue::new(&mut self.filter_f1).range(0.0..=10000.0).speed(1.0).suffix(" MHz"); + let drag = egui::DragValue::new(&mut self.filter_f1) + .range(0.0..=10000.0) + .speed(1.0) + .suffix(" MHz"); ui.add(drag); ui.label("F2:"); - let drag = egui::DragValue::new(&mut self.filter_f2).range(0.0..=10000.0).speed(1.0).suffix(" MHz"); + let drag = egui::DragValue::new(&mut self.filter_f2) + .range(0.0..=10000.0) + .speed(1.0) + .suffix(" MHz"); ui.add(drag); ui.end_row(); ui.label("F3:"); - let drag = egui::DragValue::new(&mut self.filter_f3).range(0.0..=10000.0).speed(1.0).suffix(" MHz"); + let drag = egui::DragValue::new(&mut self.filter_f3) + .range(0.0..=10000.0) + .speed(1.0) + .suffix(" MHz"); ui.add(drag); ui.label("F4:"); - let drag = egui::DragValue::new(&mut self.filter_f4).range(0.0..=10000.0).speed(1.0).suffix(" MHz"); + let drag = egui::DragValue::new(&mut self.filter_f4) + .range(0.0..=10000.0) + .speed(1.0) + .suffix(" MHz"); ui.add(drag); ui.end_row(); ui.label("Pass:"); - let drag = egui::DragValue::new(&mut self.filter_pass).range(0.0..=100.0).speed(0.2).suffix(" dB"); + let drag = egui::DragValue::new(&mut self.filter_pass) + .range(0.0..=100.0) + .speed(0.2) + .suffix(" dB"); ui.add(drag); ui.label("Stop:"); - let drag = egui::DragValue::new(&mut self.filter_stop).range(0.0..=100.0).speed(0.2).suffix(" dB"); + let drag = egui::DragValue::new(&mut self.filter_stop) + .range(0.0..=100.0) + .speed(0.2) + .suffix(" dB"); ui.add(drag); ui.end_row(); }); ui.add_space(4.0); ui.separator(); ui.add_space(4.0); - egui::Sides::new().show(ui, |_ui| {},|ui| { - if ui.button(egui::RichText::new(" Cancel ").color(Color32::RED)).clicked() { - self.filter_modal_open = false; - } - if ui.button(egui::RichText::new(" Apply filter ").color(Color32::GREEN)).clicked() { - self.filter_modal_open = false; - } - }); + egui::Sides::new().show( + ui, + |_ui| {}, + |ui| { + if ui + .button(egui::RichText::new(" Cancel ").color(Color32::RED)) + .clicked() + { + self.filter_modal_open = false; + } + if ui + .button(egui::RichText::new(" Apply filter ").color(Color32::GREEN)) + .clicked() + { + self.filter_modal_open = false; + } + }, + ); }); } From a124f88ff825aac8340d3ba670530143a6178c67 Mon Sep 17 00:00:00 2001 From: Baptistin BOILOT Date: Wed, 22 Oct 2025 16:17:32 +0200 Subject: [PATCH 3/8] Refactor the filter designer --- src/filtering.rs | 181 +++++++++++++++++++++++++++++++++++++++++- src/viewer.rs | 201 ++++++++--------------------------------------- 2 files changed, 210 insertions(+), 172 deletions(-) diff --git a/src/filtering.rs b/src/filtering.rs index b7c8af6..ce24cd6 100644 --- a/src/filtering.rs +++ b/src/filtering.rs @@ -1,5 +1,5 @@ use sci_rs::signal::filter::{ - design::{BesselThomsonNorm, DigitalFilter, FilterBandType, FilterType}, + design::{iirfilter_dyn, BesselThomsonNorm, DigitalFilter, FilterBandType, FilterOutputType, FilterType}, sosfiltfilt_dyn, }; @@ -12,7 +12,7 @@ use sci_rs::signal::filter::{ /// # Returns /// /// * A string slice representing the name of the filter type. -pub fn filter_type_name<'a>(filter_type: FilterType) -> &'a str { +fn filter_type_name<'a>(filter_type: FilterType) -> &'a str { match filter_type { FilterType::Butterworth => "Butterworth", FilterType::ChebyshevI => "Chebyshev I", @@ -33,7 +33,7 @@ pub fn filter_type_name<'a>(filter_type: FilterType) -> &'a str { /// # Returns /// /// * A string slice representing the name of the filter band type. -pub fn filter_band_type_name<'a>(filter_band_type: FilterBandType) -> &'a str { +fn filter_band_type_name<'a>(filter_band_type: FilterBandType) -> &'a str { match filter_band_type { FilterBandType::Lowpass => "Low pass", FilterBandType::Highpass => "High pass", @@ -42,6 +42,181 @@ pub fn filter_band_type_name<'a>(filter_band_type: FilterBandType) -> &'a str { } } +pub struct FilterDesigner { + filter_band_type: FilterBandType, + filter_type: FilterType, + filter_order: u32, + filter_f1: f32, + filter_f2: f32, + filter_f3: f32, + filter_f4: f32, + filter_pass: f32, + filter_stop: f32, + is_open: bool, + last_error: Option, +} + +impl FilterDesigner { + pub fn new() -> Self { + Self { + filter_band_type: FilterBandType::Lowpass, + filter_type: FilterType::Butterworth, + filter_order: 1, + filter_f1: 0.0, + filter_f2: 0.0, + filter_f3: 0.0, + filter_f4: 0.0, + filter_pass: 0.0, + filter_stop: 0.0, + is_open: false, + last_error: None, + } + } + + pub fn request_open(&mut self) { + self.is_open = true; + } + + pub fn is_open(&self) -> bool { + self.is_open + } + + pub fn 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: {} MHz", 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(filter_band_type_name(self.filter_band_type)) + .show_ui(ui, |ui| { + ui.selectable_value(&mut self.filter_band_type, FilterBandType::Lowpass, "Low pass"); + ui.selectable_value(&mut self.filter_band_type, FilterBandType::Highpass, "High pass"); + ui.selectable_value(&mut self.filter_band_type, FilterBandType::Bandpass, "Band pass"); + ui.selectable_value(&mut self.filter_band_type, FilterBandType::Bandstop, "Band stop"); + }); + egui::ComboBox::from_id_salt(egui::Id::new("filter_type")) + .selected_text(filter_type_name(self.filter_type)) + .show_ui(ui, |ui| { + ui.selectable_value(&mut self.filter_type, FilterType::Butterworth, "Butterworth"); + ui.selectable_value(&mut self.filter_type, FilterType::ChebyshevI, "Chebyshev I"); + ui.selectable_value(&mut self.filter_type, FilterType::ChebyshevII, "Chebyshev II"); + ui.selectable_value(&mut self.filter_type, FilterType::CauerElliptic, "Cauer Elliptic"); + ui.selectable_value( + &mut self.filter_type, + FilterType::BesselThomson(BesselThomsonNorm::Delay), + "Bessel Thomson", + ); + }); + ui.end_row(); + + ui.label("Order:"); + ui.add(egui::DragValue::new(&mut self.filter_order).range(1..=100).speed(1.0)); + ui.end_row(); + + ui.label("F1:"); + ui.add(egui::DragValue::new(&mut self.filter_f1).range(0.0..=fs / 2.0f32).speed(1.0).suffix(" MHz")); + ui.label("F2:"); + ui.add(egui::DragValue::new(&mut self.filter_f2).range(0.0..=fs / 2.0f32).speed(1.0).suffix(" MHz")); + ui.end_row(); + + ui.label("F3:"); + ui.add(egui::DragValue::new(&mut self.filter_f3).range(0.0..=fs / 2.0f32).speed(1.0).suffix(" MHz")); + ui.label("F4:"); + ui.add(egui::DragValue::new(&mut self.filter_f4).range(0.0..=fs / 2.0f32).speed(1.0).suffix(" MHz")); + ui.end_row(); + + ui.label("Pass:"); + ui.add(egui::DragValue::new(&mut self.filter_pass).range(0.0..=100.0).speed(0.2).suffix(" dB")); + 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(); + ui.add_space(4.0); + + egui::Sides::new().show(ui, |_ui| {}, |ui| { + if ui.button(egui::RichText::new(" Cancel ").color(egui::Color32::RED)).clicked() { + self.last_error = None; + self.is_open = false; + } + if ui.button(egui::RichText::new(" Apply filter ").color(egui::Color32::GREEN)).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()); + } + } + } + }); + }); + + result + } + + fn build_filter(&self, fs: f32) -> Result, &'static str> { + if self.filter_order == 0 { + return Err("Order must be >= 1"); + } + + let wn: Vec = match self.filter_band_type { + FilterBandType::Lowpass => { + if !(self.filter_f1 > 0.0 && self.filter_f1 < fs / 2.0) { + return Err("F1 must be in (0, fs/2)"); + } + 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)"); + } + 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("Require 0 < min(F1,F2) < max(F1,F2) < fs/2"); + } + vec![f0, f1] + } + }; + + // 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), + Some(self.filter_type), + Some(false), + Some(FilterOutputType::Sos), + Some(fs), + )) + } +} + /// Define an interface to apply filter on traces. pub trait Filtering { fn apply_filter(&mut self, filter: DigitalFilter); diff --git a/src/viewer.rs b/src/viewer.rs index 56bd4c0..9e54875 100644 --- a/src/viewer.rs +++ b/src/viewer.rs @@ -4,14 +4,14 @@ use crate::{ renderer::RENDERER_MAX_TRACE_SIZE, sync_features::SyncFeatures, tiling::{ColorScale, Gradient, TileProperties, TileSize, TileStatus, Tiling}, - util::{Fixed, format_f64_unit, format_number_unit, generate_checkboard}, + util::{format_f64_unit, format_number_unit, generate_checkboard, Fixed}, }; use egui::{ Align, Align2, Color32, DragValue, FontFamily, Key, Painter, PointerButton, Popup, PopupCloseBehavior, Rect, Sense, Shape, Stroke, TextFormat, TextureHandle, TextureOptions, Ui, pos2, text::LayoutJob, vec2, }; -use sci_rs::signal::filter::design::{BesselThomsonNorm, FilterBandType, FilterType}; +use sci_rs::signal::filter::design::DigitalFilter; use std::{ collections::HashMap, ops::Add, @@ -69,17 +69,12 @@ pub struct Viewer<'a> { autoscale_request: bool, /// Trace sampling rate in MS/s sampling_rate: f32, - filter: bool, - filter_modal_open: bool, - filter_band_type: FilterBandType, - filter_type: FilterType, - filter_order: u32, - filter_f1: f32, - filter_f2: f32, - filter_f3: f32, - filter_f4: f32, - filter_pass: f32, - filter_stop: f32, + /// Filter to apply to the 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> { @@ -129,17 +124,9 @@ impl<'a> Viewer<'a> { trace_min_max, autoscale_request: true, sampling_rate, - filter: false, - filter_modal_open: false, - filter_band_type: FilterBandType::Lowpass, - filter_type: FilterType::Butterworth, - filter_order: 1, - filter_f1: 0.0, - filter_f2: 0.0, - filter_f3: 0.0, - filter_f4: 0.0, - filter_pass: 0.0, - filter_stop: 0.0, + filter: None, + filter_request: false, + filter_designer: filtering::FilterDesigner::new(), } } @@ -230,18 +217,11 @@ impl<'a> Viewer<'a> { self.tool_step = 0; } - match self.filter { - true => { - if ui.button("Clear filter").clicked() { - self.filter = false; - } - } - false => { - if ui.button("Create filter").clicked() { - self.filter_modal_open = true; - self.filter = true; - } - } + if ui.button(match self.filter { + Some(_) => "Clear filter", + None => "Create filter" + }).clicked() { + self.filter_request = true; } if let Some(options) = sync_options { @@ -300,139 +280,22 @@ impl<'a> Viewer<'a> { ) }); - if self.filter_modal_open { - egui::Modal::new(egui::Id::new("Create filter")).show(ctx, |ui| { - ui.heading("Filter Designer"); - ui.add_space(16.0); - ui.label(format!("Sampling rate: {} MHz", self.sampling_rate)); - 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(filtering::filter_band_type_name(self.filter_band_type)) - .show_ui(ui, |ui| { - ui.selectable_value( - &mut self.filter_band_type, - FilterBandType::Lowpass, - "Low pass", - ); - ui.selectable_value( - &mut self.filter_band_type, - FilterBandType::Highpass, - "High pass", - ); - ui.selectable_value( - &mut self.filter_band_type, - FilterBandType::Bandpass, - "Band pass", - ); - ui.selectable_value( - &mut self.filter_band_type, - FilterBandType::Bandstop, - "Band stop", - ); - }); - egui::ComboBox::from_id_salt(egui::Id::new("filter_type")) - .selected_text(filtering::filter_type_name(self.filter_type)) - .show_ui(ui, |ui| { - ui.selectable_value( - &mut self.filter_type, - FilterType::Butterworth, - "Butterworth", - ); - ui.selectable_value( - &mut self.filter_type, - FilterType::ChebyshevI, - "Chebyshev I", - ); - ui.selectable_value( - &mut self.filter_type, - FilterType::ChebyshevII, - "Chebyshev II", - ); - ui.selectable_value( - &mut self.filter_type, - FilterType::CauerElliptic, - "Cauer Elliptic", - ); - ui.selectable_value( - &mut self.filter_type, - FilterType::BesselThomson(BesselThomsonNorm::Delay), - "Bessel Thomson", - ); - }); - ui.end_row(); - - ui.label("Order:"); - let drag = egui::DragValue::new(&mut self.filter_order) - .range(1..=100) - .speed(1.0); - ui.add(drag); - ui.end_row(); - - ui.label("F1:"); - let drag = egui::DragValue::new(&mut self.filter_f1) - .range(0.0..=10000.0) - .speed(1.0) - .suffix(" MHz"); - ui.add(drag); - ui.label("F2:"); - let drag = egui::DragValue::new(&mut self.filter_f2) - .range(0.0..=10000.0) - .speed(1.0) - .suffix(" MHz"); - ui.add(drag); - ui.end_row(); - ui.label("F3:"); - let drag = egui::DragValue::new(&mut self.filter_f3) - .range(0.0..=10000.0) - .speed(1.0) - .suffix(" MHz"); - ui.add(drag); - ui.label("F4:"); - let drag = egui::DragValue::new(&mut self.filter_f4) - .range(0.0..=10000.0) - .speed(1.0) - .suffix(" MHz"); - ui.add(drag); - ui.end_row(); - - ui.label("Pass:"); - let drag = egui::DragValue::new(&mut self.filter_pass) - .range(0.0..=100.0) - .speed(0.2) - .suffix(" dB"); - ui.add(drag); - ui.label("Stop:"); - let drag = egui::DragValue::new(&mut self.filter_stop) - .range(0.0..=100.0) - .speed(0.2) - .suffix(" dB"); - ui.add(drag); - ui.end_row(); - }); - ui.add_space(4.0); - ui.separator(); - ui.add_space(4.0); - egui::Sides::new().show( - ui, - |_ui| {}, - |ui| { - if ui - .button(egui::RichText::new(" Cancel ").color(Color32::RED)) - .clicked() - { - self.filter_modal_open = false; - } - if ui - .button(egui::RichText::new(" Apply filter ").color(Color32::GREEN)) - .clicked() - { - self.filter_modal_open = false; - } - }, - ); - }); + if self.filter_request { + match self.filter { + Some(_) => { + // TODO: restore original trace + self.filter = None; + self.filter_request = false; + } + None => { + self.filter_designer.request_open(); + self.filter = self.filter_designer.design_filter(ctx, self.sampling_rate); + if !self.filter_designer.is_open() { + // TODO:Check and apply filter + self.filter_request = false; + } + } + } } let response = ui.allocate_rect(viewport, Sense::drag()); From 4242893c1c7ba8364c8ce1d250c082a1e73aa042 Mon Sep 17 00:00:00 2001 From: Baptistin BOILOT Date: Tue, 28 Oct 2025 11:34:09 +0100 Subject: [PATCH 4/8] Support trace modification, backup and restore --- src/filtering.rs | 223 +++++++++++++++++++++++++++++++------------- src/main.rs | 15 ++- src/multi_viewer.rs | 11 +-- src/tiling.rs | 19 ++-- src/viewer.rs | 133 +++++++++++++++++++------- 5 files changed, 280 insertions(+), 121 deletions(-) diff --git a/src/filtering.rs b/src/filtering.rs index ce24cd6..ddcfb5f 100644 --- a/src/filtering.rs +++ b/src/filtering.rs @@ -1,6 +1,5 @@ -use sci_rs::signal::filter::{ - design::{iirfilter_dyn, BesselThomsonNorm, DigitalFilter, FilterBandType, FilterOutputType, FilterType}, - sosfiltfilt_dyn, +use sci_rs::signal::filter::design::{ + BesselThomsonNorm, DigitalFilter, FilterBandType, FilterOutputType, FilterType, iirfilter_dyn, }; /// Returns the display name for a given `FilterType`. @@ -42,14 +41,17 @@ fn filter_band_type_name<'a>(filter_band_type: FilterBandType) -> &'a str { } } +/// 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: FilterBandType, filter_type: FilterType, filter_order: u32, filter_f1: f32, filter_f2: f32, - filter_f3: f32, - filter_f4: f32, filter_pass: f32, filter_stop: f32, is_open: bool, @@ -61,19 +63,17 @@ impl FilterDesigner { Self { filter_band_type: FilterBandType::Lowpass, filter_type: FilterType::Butterworth, - filter_order: 1, + filter_order: 4, filter_f1: 0.0, filter_f2: 0.0, - filter_f3: 0.0, - filter_f4: 0.0, - filter_pass: 0.0, - filter_stop: 0.0, + filter_pass: 0.5, + filter_stop: 60.0, is_open: false, last_error: None, } } - pub fn request_open(&mut self) { + pub fn open(&mut self) { self.is_open = true; } @@ -81,7 +81,21 @@ impl FilterDesigner { self.is_open } - pub fn design_filter(&mut self, ctx: &egui::Context, fs: f32) -> Option> { + /// 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; } @@ -99,18 +113,50 @@ impl FilterDesigner { egui::ComboBox::from_id_salt(egui::Id::new("filter_band_type")) .selected_text(filter_band_type_name(self.filter_band_type)) .show_ui(ui, |ui| { - ui.selectable_value(&mut self.filter_band_type, FilterBandType::Lowpass, "Low pass"); - ui.selectable_value(&mut self.filter_band_type, FilterBandType::Highpass, "High pass"); - ui.selectable_value(&mut self.filter_band_type, FilterBandType::Bandpass, "Band pass"); - ui.selectable_value(&mut self.filter_band_type, FilterBandType::Bandstop, "Band stop"); + ui.selectable_value( + &mut self.filter_band_type, + FilterBandType::Lowpass, + "Low pass", + ); + ui.selectable_value( + &mut self.filter_band_type, + FilterBandType::Highpass, + "High pass", + ); + ui.selectable_value( + &mut self.filter_band_type, + FilterBandType::Bandpass, + "Band pass", + ); + ui.selectable_value( + &mut self.filter_band_type, + FilterBandType::Bandstop, + "Band stop", + ); }); egui::ComboBox::from_id_salt(egui::Id::new("filter_type")) .selected_text(filter_type_name(self.filter_type)) .show_ui(ui, |ui| { - ui.selectable_value(&mut self.filter_type, FilterType::Butterworth, "Butterworth"); - ui.selectable_value(&mut self.filter_type, FilterType::ChebyshevI, "Chebyshev I"); - ui.selectable_value(&mut self.filter_type, FilterType::ChebyshevII, "Chebyshev II"); - ui.selectable_value(&mut self.filter_type, FilterType::CauerElliptic, "Cauer Elliptic"); + ui.selectable_value( + &mut self.filter_type, + FilterType::Butterworth, + "Butterworth", + ); + ui.selectable_value( + &mut self.filter_type, + FilterType::ChebyshevI, + "Chebyshev I", + ); + ui.selectable_value( + &mut self.filter_type, + FilterType::ChebyshevII, + "Chebyshev II", + ); + ui.selectable_value( + &mut self.filter_type, + FilterType::CauerElliptic, + "Cauer Elliptic", + ); ui.selectable_value( &mut self.filter_type, FilterType::BesselThomson(BesselThomsonNorm::Delay), @@ -120,25 +166,43 @@ impl FilterDesigner { ui.end_row(); ui.label("Order:"); - ui.add(egui::DragValue::new(&mut self.filter_order).range(1..=100).speed(1.0)); + ui.add( + egui::DragValue::new(&mut self.filter_order) + .range(0..=16) + .speed(0.05), + ); ui.end_row(); ui.label("F1:"); - ui.add(egui::DragValue::new(&mut self.filter_f1).range(0.0..=fs / 2.0f32).speed(1.0).suffix(" MHz")); + ui.add( + egui::DragValue::new(&mut self.filter_f1) + .range(0.0..=fs / 2.0f32) + .speed(1.0) + .suffix(" MHz"), + ); ui.label("F2:"); - ui.add(egui::DragValue::new(&mut self.filter_f2).range(0.0..=fs / 2.0f32).speed(1.0).suffix(" MHz")); - ui.end_row(); - - ui.label("F3:"); - ui.add(egui::DragValue::new(&mut self.filter_f3).range(0.0..=fs / 2.0f32).speed(1.0).suffix(" MHz")); - ui.label("F4:"); - ui.add(egui::DragValue::new(&mut self.filter_f4).range(0.0..=fs / 2.0f32).speed(1.0).suffix(" MHz")); + ui.add( + egui::DragValue::new(&mut self.filter_f2) + .range(0.0..=fs / 2.0f32) + .speed(1.0) + .suffix(" MHz"), + ); ui.end_row(); ui.label("Pass:"); - ui.add(egui::DragValue::new(&mut self.filter_pass).range(0.0..=100.0).speed(0.2).suffix(" dB")); + ui.add( + egui::DragValue::new(&mut self.filter_pass) + .range(0.0..=1.0) + .speed(0.005) + .suffix(" dB"), + ); ui.label("Stop:"); - ui.add(egui::DragValue::new(&mut self.filter_stop).range(0.0..=100.0).speed(0.2).suffix(" dB")); + ui.add( + egui::DragValue::new(&mut self.filter_stop) + .range(0.0..=100.0) + .speed(0.2) + .suffix(" dB"), + ); ui.end_row(); }); @@ -151,44 +215,65 @@ impl FilterDesigner { ui.separator(); ui.add_space(4.0); - egui::Sides::new().show(ui, |_ui| {}, |ui| { - if ui.button(egui::RichText::new(" Cancel ").color(egui::Color32::RED)).clicked() { - self.last_error = None; - self.is_open = false; - } - if ui.button(egui::RichText::new(" Apply filter ").color(egui::Color32::GREEN)).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()); + egui::Sides::new().show( + ui, + |_ui| {}, + |ui| { + if ui + .button(egui::RichText::new(" Cancel ").color(egui::Color32::RED)) + .clicked() + { + self.last_error = None; + self.is_open = false; + } + if ui + .button(egui::RichText::new(" Apply filter ").color(egui::Color32::GREEN)) + .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()); + } } } - } - }); + }, + ); }); result } - fn build_filter(&self, fs: f32) -> Result, &'static str> { + /// 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 { FilterBandType::Lowpass => { if !(self.filter_f1 > 0.0 && self.filter_f1 < fs / 2.0) { - return Err("F1 must be in (0, fs/2)"); + 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)"); + return Err("F1 must be in ]0, fs/2[ interval"); } vec![self.filter_f1] } @@ -196,12 +281,32 @@ impl FilterDesigner { 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("Require 0 < min(F1,F2) < max(F1,F2) < fs/2"); + 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 { + 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, @@ -216,19 +321,3 @@ impl FilterDesigner { )) } } - -/// Define an interface to apply filter on traces. -pub trait Filtering { - fn apply_filter(&mut self, filter: DigitalFilter); -} - -/// Extends Vec to support digital filters. -impl Filtering for Vec { - fn apply_filter(&mut self, filter: DigitalFilter) { - let DigitalFilter::Sos(sos) = filter else { - panic!("Not SOS filter") - }; - let filtered: Vec = sosfiltfilt_dyn(self.iter(), &sos.sos); - *self = filtered; - } -} diff --git a/src/main.rs b/src/main.rs index 6d86af0..b38a0f9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,7 +10,7 @@ use egui::Vec2; use std::{ fs::File, io::BufReader, - sync::{Arc, Condvar, Mutex}, + sync::{Arc, Condvar, Mutex, RwLock}, thread::{self, available_parallelism}, }; @@ -74,14 +74,19 @@ fn main() { } 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(); }); } @@ -105,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(); }); } @@ -123,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 9e54875..11f90e4 100644 --- a/src/viewer.rs +++ b/src/viewer.rs @@ -4,19 +4,19 @@ use crate::{ renderer::RENDERER_MAX_TRACE_SIZE, sync_features::SyncFeatures, tiling::{ColorScale, Gradient, TileProperties, TileSize, TileStatus, Tiling}, - util::{format_f64_unit, format_number_unit, generate_checkboard, Fixed}, + util::{Fixed, format_f64_unit, format_number_unit, generate_checkboard}, }; use egui::{ Align, Align2, Color32, DragValue, FontFamily, Key, Painter, PointerButton, Popup, PopupCloseBehavior, Rect, Sense, Shape, Stroke, TextFormat, TextureHandle, TextureOptions, Ui, pos2, text::LayoutJob, vec2, }; -use sci_rs::signal::filter::design::DigitalFilter; +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. @@ -37,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. @@ -65,11 +65,13 @@ 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 to apply to the trace. + /// 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, @@ -85,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, @@ -108,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, @@ -122,6 +113,7 @@ 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, @@ -140,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) @@ -217,10 +227,13 @@ impl<'a> Viewer<'a> { self.tool_step = 0; } - if ui.button(match self.filter { - Some(_) => "Clear filter", - None => "Create filter" - }).clicked() { + if ui + .button(match self.filter { + Some(_) => "Clear filter", + None => "Create filter", + }) + .clicked() + { self.filter_request = true; } @@ -280,18 +293,27 @@ 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(_) => { - // TODO: restore original trace - self.filter = None; + // Clear filter: restore original trace if available + self.clear_filter(); self.filter_request = false; } None => { - self.filter_designer.request_open(); - self.filter = self.filter_designer.design_filter(ctx, self.sampling_rate); + self.filter_designer.open(); + self.filter = self + .filter_designer + .ui_design_filter(ctx, self.sampling_rate); if !self.filter_designer.is_open() { - // TODO:Check and apply filter + // Apply filter if one has been built + if self.filter.is_some() { + self.apply_filter(); + } self.filter_request = false; } } @@ -414,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; @@ -433,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, @@ -552,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) From 84d3c039d7c3e63041d0597e4d584643f64d7b1e Mon Sep 17 00:00:00 2001 From: Baptistin BOILOT Date: Tue, 28 Oct 2025 12:04:47 +0100 Subject: [PATCH 5/8] Update readme and changelog --- CHANGELOG.md | 1 + README.md | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) 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/README.md b/README.md index 2f1899e..f505403 100644 --- a/README.md +++ b/README.md @@ -45,10 +45,10 @@ 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). +Traces can be filtered at any time using an interactive filter designer. Click the "Create 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 MHz) 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. ``` -cargo run --release -- -s 100 --filter low-pass --cutoff-freq 1000 waveform.npy +turboplot -s 100 waveform.npy ``` 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: From d2c06c37991c29022d7da9a38272188307f31d6f Mon Sep 17 00:00:00 2001 From: Baptistin BOILOT Date: Thu, 30 Oct 2025 17:18:13 +0100 Subject: [PATCH 6/8] Add sci-rs enum wrappers --- Cargo.toml | 2 +- src/filtering.rs | 154 +++++++++++++++++++++++++++++++---------------- 2 files changed, 103 insertions(+), 53 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 76feff8..a2e7c7b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,4 +17,4 @@ npyz = "0.8.3" ndarray = "0.16.1" fixed = "1.29.0" clap = { version = "4.5.46", features = ["derive", "wrap_help"] } -sci-rs = { git = "https://github.com/bboilot-ledger/sci-rs.git", branch = "filter_type_partial_eq" } +sci-rs = "0.4.1" diff --git a/src/filtering.rs b/src/filtering.rs index ddcfb5f..afa7c59 100644 --- a/src/filtering.rs +++ b/src/filtering.rs @@ -2,42 +2,92 @@ use sci_rs::signal::filter::design::{ BesselThomsonNorm, DigitalFilter, FilterBandType, FilterOutputType, FilterType, iirfilter_dyn, }; -/// Returns the display name for a given `FilterType`. -/// -/// # Arguments -/// -/// * `filter_type` - A `FilterType` enum variant representing the type of filter. -/// -/// # Returns -/// -/// * A string slice representing the name of the filter type. -fn filter_type_name<'a>(filter_type: FilterType) -> &'a str { - match filter_type { - FilterType::Butterworth => "Butterworth", - FilterType::ChebyshevI => "Chebyshev I", - FilterType::ChebyshevII => "Chebyshev II", - FilterType::CauerElliptic => "Cauer Elliptic", - FilterType::BesselThomson(BesselThomsonNorm::Delay) => "Bessel Thomson", - FilterType::BesselThomson(BesselThomsonNorm::Phase) => "Bessel Thomson", - FilterType::BesselThomson(BesselThomsonNorm::Mag) => "Bessel Thomson", +/// 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) + ) + ) } } -/// Returns the display name for a given `FilterBandType`. -/// -/// # Arguments -/// -/// * `filter_band_type` - A `FilterBandType` enum variant representing the filter band type. -/// -/// # Returns -/// -/// * A string slice representing the name of the filter band type. -fn filter_band_type_name<'a>(filter_band_type: FilterBandType) -> &'a str { - match filter_band_type { - FilterBandType::Lowpass => "Low pass", - FilterBandType::Highpass => "High pass", - FilterBandType::Bandpass => "Band pass", - FilterBandType::Bandstop => "Band stop", +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) + ) + } +} + +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"), + } } } @@ -47,8 +97,8 @@ fn filter_band_type_name<'a>(filter_band_type: FilterBandType) -> &'a str { /// 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: FilterBandType, - filter_type: FilterType, + filter_band_type: FilterBandTypeWrapper, + filter_type: FilterTypeWrapper, filter_order: u32, filter_f1: f32, filter_f2: f32, @@ -61,8 +111,8 @@ pub struct FilterDesigner { impl FilterDesigner { pub fn new() -> Self { Self { - filter_band_type: FilterBandType::Lowpass, - filter_type: FilterType::Butterworth, + filter_band_type: FilterBandTypeWrapper(FilterBandType::Lowpass), + filter_type: FilterTypeWrapper(FilterType::Butterworth), filter_order: 4, filter_f1: 0.0, filter_f2: 0.0, @@ -111,55 +161,55 @@ impl FilterDesigner { 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(filter_band_type_name(self.filter_band_type)) + .selected_text(self.filter_band_type.to_string()) .show_ui(ui, |ui| { ui.selectable_value( &mut self.filter_band_type, - FilterBandType::Lowpass, + FilterBandTypeWrapper(FilterBandType::Lowpass), "Low pass", ); ui.selectable_value( &mut self.filter_band_type, - FilterBandType::Highpass, + FilterBandTypeWrapper(FilterBandType::Highpass), "High pass", ); ui.selectable_value( &mut self.filter_band_type, - FilterBandType::Bandpass, + FilterBandTypeWrapper(FilterBandType::Bandpass), "Band pass", ); ui.selectable_value( &mut self.filter_band_type, - FilterBandType::Bandstop, + FilterBandTypeWrapper(FilterBandType::Bandstop), "Band stop", ); }); egui::ComboBox::from_id_salt(egui::Id::new("filter_type")) - .selected_text(filter_type_name(self.filter_type)) + .selected_text(self.filter_type.to_string()) .show_ui(ui, |ui| { ui.selectable_value( &mut self.filter_type, - FilterType::Butterworth, + FilterTypeWrapper(FilterType::Butterworth), "Butterworth", ); ui.selectable_value( &mut self.filter_type, - FilterType::ChebyshevI, + FilterTypeWrapper(FilterType::ChebyshevI), "Chebyshev I", ); ui.selectable_value( &mut self.filter_type, - FilterType::ChebyshevII, + FilterTypeWrapper(FilterType::ChebyshevII), "Chebyshev II", ); ui.selectable_value( &mut self.filter_type, - FilterType::CauerElliptic, + FilterTypeWrapper(FilterType::CauerElliptic), "Cauer Elliptic", ); ui.selectable_value( &mut self.filter_type, - FilterType::BesselThomson(BesselThomsonNorm::Delay), + FilterTypeWrapper(FilterType::BesselThomson(BesselThomsonNorm::Delay)), "Bessel Thomson", ); }); @@ -264,7 +314,7 @@ impl FilterDesigner { } // Nyquist frequency verification. - let wn: Vec = match self.filter_band_type { + 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"); @@ -288,7 +338,7 @@ impl FilterDesigner { }; // Pass and stop ripple verification depending on the filter type. - match self.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"); @@ -313,8 +363,8 @@ impl FilterDesigner { wn, Some(self.filter_pass), Some(self.filter_stop), - Some(self.filter_band_type), - Some(self.filter_type), + Some(self.filter_band_type.0), + Some(self.filter_type.clone().0), Some(false), Some(FilterOutputType::Sos), Some(fs), From 1e986af11b2ebb98f0850f08538b773012d8d13b Mon Sep 17 00:00:00 2001 From: Baptistin BOILOT Date: Thu, 30 Oct 2025 17:21:26 +0100 Subject: [PATCH 7/8] Fix sampling rate unit --- README.md | 6 +----- src/filtering.rs | 2 +- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index f505403..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 at any time using an interactive filter designer. Click the "Create 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 MHz) 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. - -``` -turboplot -s 100 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 afa7c59..1a79d4e 100644 --- a/src/filtering.rs +++ b/src/filtering.rs @@ -155,7 +155,7 @@ impl FilterDesigner { modal.show(ctx, |ui| { ui.heading("Filter Designer"); ui.add_space(16.0); - ui.label(format!("Sampling rate: {} MHz", fs)); + ui.label(format!("Sampling rate: {} MS/s", fs)); ui.add_space(8.0); egui::Grid::new("filter_grid").show(ui, |ui| { From dafb7a1bb76f8155dc4eab13c8195a47bca35b30 Mon Sep 17 00:00:00 2001 From: Baptistin BOILOT Date: Thu, 30 Oct 2025 17:50:05 +0100 Subject: [PATCH 8/8] Improve filter designer UI --- src/filtering.rs | 105 +++++++++++++++++++++++++---------------------- src/viewer.rs | 2 +- 2 files changed, 56 insertions(+), 51 deletions(-) diff --git a/src/filtering.rs b/src/filtering.rs index 1a79d4e..5eaefe6 100644 --- a/src/filtering.rs +++ b/src/filtering.rs @@ -202,16 +202,6 @@ impl FilterDesigner { FilterTypeWrapper(FilterType::ChebyshevII), "Chebyshev II", ); - ui.selectable_value( - &mut self.filter_type, - FilterTypeWrapper(FilterType::CauerElliptic), - "Cauer Elliptic", - ); - ui.selectable_value( - &mut self.filter_type, - FilterTypeWrapper(FilterType::BesselThomson(BesselThomsonNorm::Delay)), - "Bessel Thomson", - ); }); ui.end_row(); @@ -223,36 +213,58 @@ impl FilterDesigner { ); ui.end_row(); - ui.label("F1:"); - ui.add( - egui::DragValue::new(&mut self.filter_f1) - .range(0.0..=fs / 2.0f32) - .speed(1.0) - .suffix(" MHz"), - ); - ui.label("F2:"); - ui.add( - egui::DragValue::new(&mut self.filter_f2) - .range(0.0..=fs / 2.0f32) - .speed(1.0) - .suffix(" MHz"), - ); + 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(); - ui.label("Pass:"); - ui.add( - egui::DragValue::new(&mut self.filter_pass) - .range(0.0..=1.0) - .speed(0.005) - .suffix(" dB"), - ); - ui.label("Stop:"); - ui.add( - egui::DragValue::new(&mut self.filter_stop) - .range(0.0..=100.0) - .speed(0.2) - .suffix(" dB"), - ); + 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"), + ); + } + + 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(); }); @@ -263,23 +275,12 @@ impl FilterDesigner { ui.add_space(4.0); ui.separator(); - ui.add_space(4.0); egui::Sides::new().show( ui, |_ui| {}, |ui| { - if ui - .button(egui::RichText::new(" Cancel ").color(egui::Color32::RED)) - .clicked() - { - self.last_error = None; - self.is_open = false; - } - if ui - .button(egui::RichText::new(" Apply filter ").color(egui::Color32::GREEN)) - .clicked() - { + if ui.button(" Apply ").clicked() { match self.build_filter(fs) { Ok(f) => { self.last_error = None; @@ -291,6 +292,10 @@ impl FilterDesigner { } } } + if ui.button(" Cancel ").clicked() { + self.last_error = None; + self.is_open = false; + } }, ); }); diff --git a/src/viewer.rs b/src/viewer.rs index 11f90e4..9dd44b9 100644 --- a/src/viewer.rs +++ b/src/viewer.rs @@ -230,7 +230,7 @@ impl<'a> Viewer<'a> { if ui .button(match self.filter { Some(_) => "Clear filter", - None => "Create filter", + None => "Filter", }) .clicked() {