From 4cbf8bff6d8769ae1b3a44961df0b950b5bcddc6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Stefa=C5=84ski?= Date: Sun, 24 May 2026 15:22:58 +0200 Subject: [PATCH] plotter refactor --- svldplotter/src/main.rs | 337 ++++---------------------------------- svldplotter/src/plot.rs | 131 +++++++++++++++ svldplotter/src/signal.rs | 102 ++++++++++++ svldplotter/src/tree.rs | 122 ++++++++++++++ 4 files changed, 385 insertions(+), 307 deletions(-) create mode 100644 svldplotter/src/plot.rs create mode 100644 svldplotter/src/signal.rs create mode 100644 svldplotter/src/tree.rs diff --git a/svldplotter/src/main.rs b/svldplotter/src/main.rs index 3d3d468..a32ce4b 100644 --- a/svldplotter/src/main.rs +++ b/svldplotter/src/main.rs @@ -1,33 +1,25 @@ +mod plot; +mod signal; +mod tree; + use std::{fs, path::Path}; use can_dbc::Dbc; use eframe::egui; -use egui::{ScrollArea, Vec2b}; -use egui_ltreeview::{Action, TreeView}; -use egui_plot::{AxisHints, Legend, Line, Plot, PlotPoint, PlotPoints, VPlacement}; use libsvld::DecodeResult; +use crate::plot::show_plot_panel; +use crate::signal::{compute_signal, PlottedSignal}; +use crate::tree::{build_tree, decode_signal_node_id, show_side_panel, MsgNode}; + fn main() { let native_options = eframe::NativeOptions::default(); - eframe::run_native("My egui App", native_options, Box::new(|cc| Ok(Box::new(MyEguiApp::new(cc))))).unwrap(); -} - -struct SigNode { - id: u64, - name: String, -} - -struct MsgNode { - id: u64, - label: String, - signals: Vec, -} - -struct PlottedSignal { - id: u64, - label: String, - unit: String, - points: Vec, + eframe::run_native( + "My egui App", + native_options, + Box::new(|cc| Ok(Box::new(MyEguiApp::new(cc)))), + ) + .unwrap(); } struct MyEguiApp { @@ -40,16 +32,14 @@ struct MyEguiApp { impl MyEguiApp { fn new(_cc: &eframe::CreationContext<'_>) -> Self { - let args: Vec::<_> = std::env::args().collect(); + let args: Vec<_> = std::env::args().collect(); - let data = + let dbc_text = fs::read_to_string(Path::new(&args[1])).expect("Unable to read input file"); - let dbc = Dbc::try_from(data.as_str()).expect("Failed to parse dbc file"); + let dbc = Dbc::try_from(dbc_text.as_str()).expect("Failed to parse dbc file"); let asc_path = Path::new(&args[2]); - let data = libsvld::decode(&dbc, asc_path); - let tree = build_tree(&dbc, &data); Self { @@ -60,291 +50,24 @@ impl MyEguiApp { cursor_x: None, } } -} -fn compute_signal( - data: &DecodeResult, - msg_id: u32, - sig_idx: usize, - id: u64, -) -> Option { - let meta = data.metadata.get(&msg_id)?; - let sig = meta.signals.get(sig_idx)?; - let frames = data.data.get(&msg_id)?; - let mut points = Vec::with_capacity(frames.len()); - for frame in frames { - let bytes = u64::from_le_bytes(frame.data); - points.push(PlotPoint { - x: frame.timestamp, - y: libsvld::motec::extract_signal(bytes, &sig.signal), - }); - } - Some(PlottedSignal { - id, - label: sig.name.clone(), - unit: sig.unit.clone(), - points, - }) -} - -fn build_tree(dbc: &Dbc, data: &DecodeResult) -> Vec { - let mut msgs: Vec<&libsvld::motec::MessageMetadata> = data.metadata.values().collect(); - msgs.sort_by_key(|m| m.id); - msgs.into_iter() - .map(|m| { - let name = dbc - .messages - .iter() - .find(|x| x.id.raw() == m.id) - .map(|x| x.name.as_str()) - .unwrap_or("?"); - let label = format!("0x{:X} {}", m.id, name); - let signals = m - .signals - .iter() - .enumerate() - .map(|(i, s)| SigNode { - id: (1u64 << 63) | ((m.id as u64) << 16) | (i as u64), - name: s.name.clone(), - }) - .collect(); - MsgNode { - id: m.id as u64, - label, - signals, + fn apply_toggles(&mut self, toggles: Vec) { + for id in toggles { + if let Some(pos) = self.plotted.iter().position(|p| p.id == id) { + self.plotted.remove(pos); + } else if let Some((msg_id, sig_idx)) = decode_signal_node_id(id) { + if let Some(sig) = compute_signal(&self.data, msg_id, sig_idx, id) { + self.plotted.push(sig); + } } - }) - .collect() -} - -fn nearest_y(points: &[PlotPoint], x: f64) -> Option { - if points.is_empty() { - return None; - } - let i = points.partition_point(|p| p.x < x); - let cand = [ - i.checked_sub(1).map(|j| &points[j]), - points.get(i), - ]; - cand.into_iter() - .flatten() - .min_by(|a, b| (a.x - x).abs().total_cmp(&(b.x - x).abs())) - .map(|p| p.y) -} - -// Min/max bucketing: for each horizontal pixel column in [x_min, x_max], emit -// the min-y and max-y point so spikes survive decimation. Pushed in x order -// (min/max swapped if needed) to keep the line monotonically increasing in x. -fn decimate( - points: &[PlotPoint], - x_min: f64, - x_max: f64, - buckets: usize, - out: &mut Vec, -) { - out.clear(); - if points.is_empty() || buckets == 0 || !(x_max > x_min) { - return; - } - - let lo = points.partition_point(|p| p.x < x_min).saturating_sub(1); - let hi = (points.partition_point(|p| p.x <= x_max) + 1).min(points.len()); - let visible = &points[lo..hi]; - - if visible.len() <= buckets * 2 { - out.extend_from_slice(visible); - return; - } - - let bucket_w = (x_max - x_min) / buckets as f64; - let mut i = 0; - for b in 0..buckets { - let b_end = x_min + (b as f64 + 1.0) * bucket_w; - let start = i; - while i < visible.len() && visible[i].x < b_end { - i += 1; - } - if start == i { - continue; - } - let slice = &visible[start..i]; - let mut min_p = slice[0]; - let mut max_p = slice[0]; - for p in &slice[1..] { - if p.y < min_p.y { min_p = *p; } - if p.y > max_p.y { max_p = *p; } - } - if min_p.x <= max_p.x { - out.push(min_p); - out.push(max_p); - } else { - out.push(max_p); - out.push(min_p); } } } impl eframe::App for MyEguiApp { - fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { - egui::Panel::left(egui::Id::new("Signals")) - .resizable(true) - .show_inside(ui, |ui| { - ui.add( - egui::TextEdit::singleline(&mut self.filter) - .hint_text("Filter messages/signals") - .desired_width(f32::INFINITY), - ); - let needle = self.filter.trim().to_lowercase(); - let mut toggles: Vec = Vec::new(); - ScrollArea::both() - .scroll([ - true, - true - ]) - .scroll_bar_visibility( - egui::scroll_area::ScrollBarVisibility::VisibleWhenNeeded - ) - .show(ui, |ui| { - let (_resp, actions) = TreeView::::new( - ui.make_persistent_id("SignalsTree"), - ) - .show(ui, |builder| { - for msg in &self.tree { - let msg_hit = needle.is_empty() - || msg.label.to_lowercase().contains(&needle); - let matching_sigs: Vec<&SigNode> = if msg_hit { - msg.signals.iter().collect() - } else { - msg.signals - .iter() - .filter(|s| s.name.to_lowercase().contains(&needle)) - .collect() - }; - if !msg_hit && matching_sigs.is_empty() { - continue; - } - builder.dir(msg.id, msg.label.as_str()); - for sig in matching_sigs { - builder.leaf(sig.id, sig.name.as_str()); - } - builder.close_dir(); - } - }); - for action in actions { - if let Action::SetSelected(ids) = action { - for id in ids { - if id & (1u64 << 63) != 0 { - toggles.push(id); - } - } - } - } - }); - for id in toggles { - if let Some(pos) = self.plotted.iter().position(|p| p.id == id) { - self.plotted.remove(pos); - } else { - let msg_id = ((id >> 16) & 0xFFFF_FFFF) as u32; - let sig_idx = (id & 0xFFFF) as usize; - if let Some(sig) = compute_signal(&self.data, msg_id, sig_idx, id) { - self.plotted.push(sig); - } - } - } - }); - egui::CentralPanel::default().show_inside(ui, |ui| { - if self.plotted.is_empty() { - ui.centered_and_justified(|ui| { - ui.label("Click a signal in the tree to plot it."); - }); - return; - } - - // Group signals by unit, preserving the order each unit first appeared. - let mut groups: Vec<(String, Vec)> = Vec::new(); - for (i, sig) in self.plotted.iter().enumerate() { - if let Some(g) = groups.iter_mut().find(|(u, _)| u == &sig.unit) { - g.1.push(i); - } else { - groups.push((sig.unit.clone(), vec![i])); - } - } - - let full_min = self - .plotted - .iter() - .filter_map(|s| s.points.first().map(|p| p.x)) - .fold(f64::INFINITY, f64::min); - let full_max = self - .plotted - .iter() - .filter_map(|s| s.points.last().map(|p| p.x)) - .fold(f64::NEG_INFINITY, f64::max); - - let link_group = ui.id().with("plot-link"); - let n = groups.len(); - // ui.spacing_mut().item_spacing.y = 0.0; - let h = (ui.available_height() / n as f32).max(80.0); - - let cursor_x = self.cursor_x; - let mut new_cursor_x: Option = None; - - for (gi, (unit, idxs)) in groups.iter().enumerate() { - let is_first = gi == 0; - let is_last = gi + 1 == n; - - let mut x_axes: Vec = Vec::new(); - if is_first { - x_axes.push(AxisHints::new_x().placement(VPlacement::Top)); - } - if is_last { - x_axes.push(AxisHints::new_x().placement(VPlacement::Bottom)); - } - - let title = if unit.is_empty() { "(no unit)" } else { unit.as_str() }; - - let plot = Plot::new(("plot", title)) - .height(h) - .include_x(full_min) - .include_x(full_max) - .allow_zoom(Vec2b { x: true, y: true }) - .link_axis(link_group, Vec2b { x: true, y: false }) - .link_cursor(link_group, Vec2b { x: true, y: false }) - .legend(Legend::default()) - .custom_x_axes(x_axes) - .custom_y_axes(vec![AxisHints::new_y().min_thickness(48.0)]); - - let plotted = &self.plotted; - let plot_resp = plot.show(ui, |pui| { - let bounds = pui.plot_bounds(); - let x_min = bounds.min()[0]; - let x_max = bounds.max()[0]; - let px_w = pui.transform().frame().width() as usize; - let mut buf = Vec::new(); - for &i in idxs { - let sig = &plotted[i]; - decimate(&sig.points, x_min, x_max, px_w.max(1), &mut buf); - let ppoints = PlotPoints::Owned(std::mem::take(&mut buf)); - let unit_s = if sig.unit.is_empty() { - String::new() - } else { - format!(" [{}]", sig.unit) - }; - let name = match cursor_x.and_then(|x| nearest_y(&sig.points, x)) { - Some(v) => format!("{}: {:.4}{}", sig.label, v, unit_s), - None => format!("{}{}", sig.label, unit_s), - }; - pui.line(Line::new(name, ppoints)); - } - - pui.pointer_coordinate().map(|c| c.x) - }); - if let Some(x) = plot_resp.inner { - new_cursor_x = Some(x); - } - } - - self.cursor_x = new_cursor_x; - }); - } + fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { + let toggles = show_side_panel(ui, &mut self.filter, &self.tree); + self.apply_toggles(toggles); + show_plot_panel(ui, &self.plotted, &mut self.cursor_x); + } } diff --git a/svldplotter/src/plot.rs b/svldplotter/src/plot.rs new file mode 100644 index 0000000..a672087 --- /dev/null +++ b/svldplotter/src/plot.rs @@ -0,0 +1,131 @@ +use eframe::egui; +use egui::Vec2b; +use egui_plot::{AxisHints, Legend, Line, Plot, PlotPoints, VPlacement}; + +use crate::signal::{decimate, nearest_y, PlottedSignal}; + +const MIN_PLOT_HEIGHT: f32 = 80.0; +const Y_AXIS_MIN_THICKNESS: f32 = 48.0; + +struct UnitGroup<'a> { + unit: &'a str, + signals: Vec<&'a PlottedSignal>, +} + +/// Group signals by unit, preserving the order each unit first appeared. +fn group_by_unit(signals: &[PlottedSignal]) -> Vec> { + let mut groups: Vec> = Vec::new(); + for sig in signals { + if let Some(g) = groups.iter_mut().find(|g| g.unit == sig.unit) { + g.signals.push(sig); + } else { + groups.push(UnitGroup { + unit: &sig.unit, + signals: vec![sig], + }); + } + } + groups +} + +fn full_x_range(signals: &[PlottedSignal]) -> (f64, f64) { + let min = signals + .iter() + .filter_map(|s| s.points.first().map(|p| p.x)) + .fold(f64::INFINITY, f64::min); + let max = signals + .iter() + .filter_map(|s| s.points.last().map(|p| p.x)) + .fold(f64::NEG_INFINITY, f64::max); + (min, max) +} + +fn line_label(sig: &PlottedSignal, cursor_x: Option) -> String { + let unit_suffix = if sig.unit.is_empty() { + String::new() + } else { + format!(" [{}]", sig.unit) + }; + match cursor_x.and_then(|x| nearest_y(&sig.points, x)) { + Some(v) => format!("{}: {:.4}{}", sig.label, v, unit_suffix), + None => format!("{}{}", sig.label, unit_suffix), + } +} + +pub fn show_plot_panel( + ui: &mut egui::Ui, + plotted: &[PlottedSignal], + cursor_x: &mut Option, +) { + egui::CentralPanel::default().show_inside(ui, |ui| { + if plotted.is_empty() { + ui.centered_and_justified(|ui| { + ui.label("Click a signal in the tree to plot it."); + }); + return; + } + + let groups = group_by_unit(plotted); + let (full_min, full_max) = full_x_range(plotted); + + let link_group = ui.id().with("plot-link"); + let plot_count = groups.len(); + let plot_height = + (ui.available_height() / plot_count as f32).max(MIN_PLOT_HEIGHT); + + let prev_cursor_x = *cursor_x; + let mut new_cursor_x: Option = None; + + for (group_index, group) in groups.iter().enumerate() { + let is_first = group_index == 0; + let is_last = group_index + 1 == plot_count; + + let mut x_axes: Vec = Vec::new(); + if is_first { + x_axes.push(AxisHints::new_x().placement(VPlacement::Top)); + } + if is_last { + x_axes.push(AxisHints::new_x().placement(VPlacement::Bottom)); + } + + let title = if group.unit.is_empty() { + "(no unit)" + } else { + group.unit + }; + + let plot = Plot::new(("plot", title)) + .height(plot_height) + .include_x(full_min) + .include_x(full_max) + .allow_zoom(Vec2b { x: true, y: true }) + .link_axis(link_group, Vec2b { x: true, y: false }) + .link_cursor(link_group, Vec2b { x: true, y: false }) + .legend(Legend::default()) + .custom_x_axes(x_axes) + .custom_y_axes(vec![ + AxisHints::new_y().min_thickness(Y_AXIS_MIN_THICKNESS), + ]); + + let response = plot.show(ui, |plot_ui| { + let bounds = plot_ui.plot_bounds(); + let x_min = bounds.min()[0]; + let x_max = bounds.max()[0]; + let pixel_width = plot_ui.transform().frame().width() as usize; + let mut buf = Vec::new(); + for sig in &group.signals { + decimate(&sig.points, x_min, x_max, pixel_width.max(1), &mut buf); + let plot_points = PlotPoints::Owned(std::mem::take(&mut buf)); + let name = line_label(sig, prev_cursor_x); + plot_ui.line(Line::new(name, plot_points)); + } + plot_ui.pointer_coordinate().map(|c| c.x) + }); + if let Some(x) = response.inner { + new_cursor_x = Some(x); + } + } + + *cursor_x = new_cursor_x; + }); +} diff --git a/svldplotter/src/signal.rs b/svldplotter/src/signal.rs new file mode 100644 index 0000000..46f3393 --- /dev/null +++ b/svldplotter/src/signal.rs @@ -0,0 +1,102 @@ +use egui_plot::PlotPoint; +use libsvld::DecodeResult; + +pub struct PlottedSignal { + pub id: u64, + pub label: String, + pub unit: String, + pub points: Vec, +} + +pub fn compute_signal( + data: &DecodeResult, + msg_id: u32, + sig_idx: usize, + id: u64, +) -> Option { + let meta = data.metadata.get(&msg_id)?; + let sig = meta.signals.get(sig_idx)?; + let frames = data.data.get(&msg_id)?; + let mut points = Vec::with_capacity(frames.len()); + for frame in frames { + let bytes = u64::from_le_bytes(frame.data); + points.push(PlotPoint { + x: frame.timestamp, + y: libsvld::motec::extract_signal(bytes, &sig.signal), + }); + } + Some(PlottedSignal { + id, + label: sig.name.clone(), + unit: sig.unit.clone(), + points, + }) +} + +pub fn nearest_y(points: &[PlotPoint], x: f64) -> Option { + if points.is_empty() { + return None; + } + let i = points.partition_point(|p| p.x < x); + let candidates = [ + i.checked_sub(1).map(|j| &points[j]), + points.get(i), + ]; + candidates + .into_iter() + .flatten() + .min_by(|a, b| (a.x - x).abs().total_cmp(&(b.x - x).abs())) + .map(|p| p.y) +} + +// Min/max bucketing: for each horizontal pixel column in [x_min, x_max], emit +// the min-y and max-y point so spikes survive decimation. Pushed in x order +// (min/max swapped if needed) to keep the line monotonically increasing in x. +pub fn decimate( + points: &[PlotPoint], + x_min: f64, + x_max: f64, + buckets: usize, + out: &mut Vec, +) { + out.clear(); + if points.is_empty() || buckets == 0 || !(x_max > x_min) { + return; + } + + let start = points.partition_point(|p| p.x < x_min).saturating_sub(1); + let end = (points.partition_point(|p| p.x <= x_max) + 1).min(points.len()); + let visible = &points[start..end]; + + if visible.len() <= buckets * 2 { + out.extend_from_slice(visible); + return; + } + + let bucket_w = (x_max - x_min) / buckets as f64; + let mut cursor = 0; + for bucket in 0..buckets { + let bucket_end_x = x_min + (bucket as f64 + 1.0) * bucket_w; + let bucket_start = cursor; + while cursor < visible.len() && visible[cursor].x < bucket_end_x { + cursor += 1; + } + if bucket_start == cursor { + continue; + } + let slice = &visible[bucket_start..cursor]; + let mut min_p = slice[0]; + let mut max_p = slice[0]; + for p in &slice[1..] { + if p.y < min_p.y { min_p = *p; } + if p.y > max_p.y { max_p = *p; } + } + if min_p.x <= max_p.x { + out.push(min_p); + out.push(max_p); + } else { + out.push(max_p); + out.push(min_p); + } + } +} diff --git a/svldplotter/src/tree.rs b/svldplotter/src/tree.rs new file mode 100644 index 0000000..8c770d4 --- /dev/null +++ b/svldplotter/src/tree.rs @@ -0,0 +1,122 @@ +use can_dbc::Dbc; +use eframe::egui; +use egui::ScrollArea; +use egui_ltreeview::{Action, TreeView}; +use libsvld::DecodeResult; + +pub struct SigNode { + pub id: u64, + pub name: String, +} + +pub struct MsgNode { + pub id: u64, + pub label: String, + pub signals: Vec, +} + +const SIGNAL_FLAG: u64 = 1 << 63; + +pub fn signal_node_id(msg_id: u32, sig_idx: usize) -> u64 { + SIGNAL_FLAG | ((msg_id as u64) << 16) | (sig_idx as u64) +} + +pub fn decode_signal_node_id(id: u64) -> Option<(u32, usize)> { + if id & SIGNAL_FLAG == 0 { + return None; + } + let msg_id = ((id >> 16) & 0xFFFF_FFFF) as u32; + let sig_idx = (id & 0xFFFF) as usize; + Some((msg_id, sig_idx)) +} + +pub fn build_tree(dbc: &Dbc, data: &DecodeResult) -> Vec { + let mut msgs: Vec<&libsvld::motec::MessageMetadata> = data.metadata.values().collect(); + msgs.sort_by_key(|m| m.id); + msgs.into_iter() + .map(|msg| { + let name = dbc + .messages + .iter() + .find(|x| x.id.raw() == msg.id) + .map(|x| x.name.as_str()) + .unwrap_or("?"); + let label = format!("0x{:X} {}", msg.id, name); + let signals = msg + .signals + .iter() + .enumerate() + .map(|(sig_idx, signal)| SigNode { + id: signal_node_id(msg.id, sig_idx), + name: signal.name.clone(), + }) + .collect(); + MsgNode { + id: msg.id as u64, + label, + signals, + } + }) + .collect() +} + +/// Render the side panel and return any signal IDs the user clicked to toggle. +pub fn show_side_panel( + ui: &mut egui::Ui, + filter: &mut String, + tree: &[MsgNode], +) -> Vec { + let mut toggles = Vec::new(); + egui::Panel::left(egui::Id::new("Signals")) + .resizable(true) + .show_inside(ui, |ui| { + ui.add( + egui::TextEdit::singleline(filter) + .hint_text("Filter messages/signals") + .desired_width(f32::INFINITY), + ); + let needle = filter.trim().to_lowercase(); + ScrollArea::both() + .scroll([true, true]) + .scroll_bar_visibility( + egui::scroll_area::ScrollBarVisibility::VisibleWhenNeeded, + ) + .show(ui, |ui| { + let (_resp, actions) = TreeView::::new( + ui.make_persistent_id("SignalsTree"), + ) + .show(ui, |builder| { + for msg in tree { + let msg_hit = needle.is_empty() + || msg.label.to_lowercase().contains(&needle); + let matching: Vec<&SigNode> = if msg_hit { + msg.signals.iter().collect() + } else { + msg.signals + .iter() + .filter(|s| s.name.to_lowercase().contains(&needle)) + .collect() + }; + if !msg_hit && matching.is_empty() { + continue; + } + builder.dir(msg.id, msg.label.as_str()); + for sig in matching { + builder.leaf(sig.id, sig.name.as_str()); + } + builder.close_dir(); + } + }); + for action in actions { + if let Action::SetSelected(ids) = action { + for id in ids { + if id & SIGNAL_FLAG != 0 { + toggles.push(id); + } + } + } + } + }); + }); + toggles +}