From d95b29fb7f7147af12ae8d5c9e57eeb0a141766f Mon Sep 17 00:00:00 2001 From: Simon Gardling Date: Tue, 2 Dec 2025 23:10:28 -0500 Subject: [PATCH] implement intesections + misc function options --- TODO.md | 6 --- build.rs | 7 +++- src/function_entry.rs | 17 +++++++- src/function_manager.rs | 89 +++++++++++++++++++++++++++++++++++++++++ src/math_app.rs | 81 +++++++++++++++++++++++++------------ src/misc.rs | 44 ++++++++++++++++++++ tests/function.rs | 1 + 7 files changed, 210 insertions(+), 35 deletions(-) diff --git a/TODO.md b/TODO.md index bf055ca..83f363f 100644 --- a/TODO.md +++ b/TODO.md @@ -1,14 +1,8 @@ ## TODO: 1. Function management - Integrals between functions (too hard to implement, maybe will shelve) - - Display intersection between functions (would have to rewrite a lot of the function plotting handling) - - [Drag and drop support](https://github.com/emilk/egui/discussions/1530) in the UI to re-order functions - - Hide/disable functions - Prevent user from making too many function entries - - Display function errors as tooltips or a warning box (not preventing the display of the graph) - - Clone functions 2. Smart display of graph - - Display of intersections between functions 3. Allow constants in min/max integral input (like pi or euler's number) 4. Sliding values for functions (like a user-interactable slider that adjusts a variable in the function, like desmos) 5. Fix integral display diff --git a/build.rs b/build.rs index 6fa76d3..6e44e2c 100644 --- a/build.rs +++ b/build.rs @@ -131,7 +131,12 @@ fn main() { "emoji-icon-font".to_owned(), Arc::new( FontData::from_owned( - font_stripper("emoji-icon-font.ttf", "emoji-icon.ttf", vec!['⚙']).unwrap(), + font_stripper( + "emoji-icon-font.ttf", + "emoji-icon.ttf", + vec!['⚙', '⎘', '👁', '○', '⬆', '⬇', '⚠'], + ) + .unwrap(), ) .tweak(FontTweak { scale: 0.8, diff --git a/src/function_entry.rs b/src/function_entry.rs index e2a9f69..c5ce4f2 100644 --- a/src/function_entry.rs +++ b/src/function_entry.rs @@ -45,6 +45,9 @@ pub struct FunctionEntry { pub nth_derviative: bool, + /// If the function is visible on the graph + pub visible: bool, + pub back_data: Vec, pub integral_data: Option<(Vec, f64)>, pub derivative_data: Vec, @@ -67,6 +70,7 @@ impl Hash for FunctionEntry { self.nth_derviative.hash(state); self.curr_nth.hash(state); self.settings_opened.hash(state); + self.visible.hash(state); } } @@ -75,11 +79,12 @@ impl Serialize for FunctionEntry { where S: Serializer, { - let mut s = serializer.serialize_struct("FunctionEntry", 4)?; + let mut s = serializer.serialize_struct("FunctionEntry", 5)?; s.serialize_field("raw_func_str", &self.raw_func_str)?; s.serialize_field("integral", &self.integral)?; s.serialize_field("derivative", &self.derivative)?; s.serialize_field("curr_nth", &self.curr_nth)?; + s.serialize_field("visible", &self.visible)?; s.end() } @@ -96,6 +101,12 @@ impl<'de> Deserialize<'de> for FunctionEntry { integral: bool, derivative: bool, curr_nth: usize, + #[serde(default = "default_visible")] + visible: bool, + } + + fn default_visible() -> bool { + true } let helper = Helper::deserialize(deserializer)?; @@ -115,6 +126,7 @@ impl<'de> Deserialize<'de> for FunctionEntry { new_func_entry.integral = helper.integral; new_func_entry.derivative = helper.derivative; new_func_entry.curr_nth = helper.curr_nth; + new_func_entry.visible = helper.visible; Ok(new_func_entry) } @@ -129,6 +141,7 @@ impl Default for FunctionEntry { integral: false, derivative: false, nth_derviative: false, + visible: true, back_data: Vec::new(), integral_data: None, derivative_data: Vec::new(), @@ -374,7 +387,7 @@ impl FunctionEntry { settings: &AppSettings, main_plot_color: Color32, ) -> Option { - if self.test_result.is_some() | self.function.is_none() { + if self.test_result.is_some() | self.function.is_none() | !self.visible { return None; } diff --git a/src/function_manager.rs b/src/function_manager.rs index ef581f2..ef83ddd 100644 --- a/src/function_manager.rs +++ b/src/function_manager.rs @@ -86,9 +86,14 @@ impl FunctionManager { let initial_hash = self.get_hash(); let can_remove = self.functions.len() > 1; + let can_add = self.functions.len() < COLORS.len(); + let num_functions = self.functions.len(); let available_width = ui.available_width(); let mut remove_i: Option = None; + let mut clone_i: Option = None; + let mut move_up_i: Option = None; + let mut move_down_i: Option = None; let target_size = vec2(available_width, crate::consts::FONT_SIZE); for (i, (te_id, function)) in self.functions.iter_mut().map(|(a, b)| (*a, b)).enumerate() { let mut new_string = function.autocomplete.string.clone(); @@ -116,6 +121,19 @@ impl FunctionManager { // Only keep valid chars new_string.retain(crate::misc::is_valid_char); + // Display error indicator with tooltip if there's a parsing error + if let Some(error) = function.get_test_result() { + ui.horizontal(|ui| { + ui.label(egui::RichText::new("⚠").color(egui::Color32::YELLOW)) + .on_hover_text(error); + ui.label( + egui::RichText::new(error) + .color(egui::Color32::LIGHT_RED) + .small(), + ); + }); + } + // If not fully open, return here as buttons cannot yet be displayed, therefore the user is inable to mark it for deletion let animate_bool = ui.ctx().animate_bool(te_id, re.has_focus()); if animate_bool == 1.0 { @@ -197,6 +215,47 @@ impl FunctionManager { remove_i = Some(i); } + // Toggle visibility + function.visible.bitxor_assign( + ui.add(button_area_button(if function.visible { + "👁" + } else { + "○" + })) + .on_hover_text(match function.visible { + true => "Hide Function", + false => "Show Function", + }) + .clicked(), + ); + + // Clone function + if ui + .add_enabled(can_add, button_area_button("⎘")) + .on_hover_text("Clone Function") + .clicked() + { + clone_i = Some(i); + } + + // Move up (only if not first) + if ui + .add_enabled(i > 0, button_area_button("⬆")) + .on_hover_text("Move Up") + .clicked() + { + move_up_i = Some(i); + } + + // Move down (only if not last) + if ui + .add_enabled(i < num_functions - 1, button_area_button("⬇")) + .on_hover_text("Move Down") + .clicked() + { + move_down_i = Some(i); + } + ui.add_enabled_ui(function.is_some(), |ui| { // Toggle integral being enabled or not function.integral.bitxor_assign( @@ -240,6 +299,26 @@ impl FunctionManager { self.functions.remove(remove_i_unwrap); } + // Clone function if the user requests it + if let Some(clone_i_unwrap) = clone_i { + let cloned = self.functions[clone_i_unwrap].1.clone(); + self.push_cloned(cloned); + } + + // Move function up if the user requests it + if let Some(i) = move_up_i + && i > 0 + { + self.functions.swap(i, i - 1); + } + + // Move function down if the user requests it + if let Some(i) = move_down_i + && i < self.functions.len() - 1 + { + self.functions.swap(i, i + 1); + } + let final_hash = self.get_hash(); initial_hash != final_hash @@ -253,6 +332,16 @@ impl FunctionManager { )); } + /// Push a cloned function entry + pub fn push_cloned(&mut self, mut entry: FunctionEntry) { + // Reset settings_opened so the cloned function doesn't have settings open + entry.settings_opened = false; + self.functions.push(( + create_id(random_u64().expect("unable to generate random id")), + entry, + )); + } + /// Detect if any functions are using integrals pub fn any_using_integral(&self) -> bool { self.functions.iter().any(|(_, func)| func.integral) diff --git a/src/math_app.rs b/src/math_app.rs index 8258224..01ca817 100644 --- a/src/math_app.rs +++ b/src/math_app.rs @@ -2,7 +2,7 @@ use crate::{ consts::{BUILD_INFO, COLORS, DEFAULT_INTEGRAL_NUM, DEFAULT_MAX_X, DEFAULT_MIN_X, build}, function_entry::Riemann, function_manager::FunctionManager, - misc::option_vec_printer, + misc::{EguiHelper, find_intersections, option_vec_printer}, }; use eframe::App; use egui::{ @@ -13,7 +13,7 @@ use egui_plot::Plot; use emath::{Align, Align2}; use epaint::{CornerRadius, Margin}; -use itertools::Itertools; + use std::{io::Read, ops::BitXorAssign}; use web_time::Instant; @@ -47,6 +47,9 @@ pub struct AppSettings { /// Stores whether or not displaying roots is enabled pub do_roots: bool, + /// Stores whether or not displaying intersections between functions is enabled + pub do_intersections: bool, + /// Stores current plot pixel width pub plot_width: usize, } @@ -64,6 +67,7 @@ impl Default for AppSettings { integral_num: DEFAULT_INTEGRAL_NUM, do_extrema: true, do_roots: true, + do_intersections: true, plot_width: 0, } } @@ -108,6 +112,9 @@ pub struct MathApp { /// Stores settings (pretty self-explanatory) settings: AppSettings, + + /// Stores intersection points between functions + intersections: Vec, } #[cfg(target_arch = "wasm32")] @@ -246,6 +253,7 @@ impl MathApp { last_info: (None, None), opened: Opened::default(), settings: AppSettings::default(), + intersections: Vec::new(), } } @@ -354,6 +362,15 @@ impl MathApp { }) .clicked(), ); + + self.settings.do_intersections.bitxor_assign( + ui.add(Button::new("Intersections")) + .on_hover_text(match self.settings.do_intersections { + true => "Disable Displaying Intersections", + false => "Display Intersections between functions", + }) + .clicked(), + ); }); if self.functions.display_entries(ui) { @@ -530,7 +547,7 @@ impl App for MathApp { self.side_panel(ctx); } - // Central panel which contains the central plot (or an error created when parsing) + // Central panel which contains the central plot CentralPanel::default() .frame(Frame { inner_margin: Margin::ZERO, @@ -540,29 +557,6 @@ impl App for MathApp { ..Frame::NONE }) .show(ctx, |ui| { - // Display an error if it exists - let errors_formatted: String = self - .functions - .get_entries() - .iter() - .map(|(_, func)| func.get_test_result()) - .enumerate() - .filter(|(_, error)| error.is_some()) - .map(|(i, error)| { - // use unwrap_unchecked as None Errors are already filtered out - unsafe { - format!("(Function #{}) {}\n", i, error.as_ref().unwrap_unchecked()) - } - }) - .join(""); - - if !errors_formatted.is_empty() { - ui.centered_and_justified(|ui| { - ui.heading(errors_formatted); - }); - return; - } - let available_width: usize = (ui.available_width() as usize) + 1; // Used in later logic let width_changed = available_width != self.settings.plot_width; self.settings.plot_width = available_width; @@ -607,6 +601,41 @@ impl App for MathApp { }) .collect(); + // Calculate and display intersections between functions + if self.settings.do_intersections { + let entries = self.functions.get_entries(); + let visible_entries: Vec<_> = entries + .iter() + .filter(|(_, f)| f.visible && f.is_some()) + .collect(); + + // Clear previous intersections + self.intersections.clear(); + + // Find intersections between all pairs of visible functions + for i in 0..visible_entries.len() { + for j in (i + 1)..visible_entries.len() { + let (_, func1) = visible_entries[i]; + let (_, func2) = visible_entries[j]; + + let mut intersections = + find_intersections(&func1.back_data, &func2.back_data); + self.intersections.append(&mut intersections); + } + } + + // Display intersection points + if !self.intersections.is_empty() { + plot_ui.points( + self.intersections + .clone() + .to_points() + .color(Color32::from_rgb(255, 105, 180)) // Hot pink for visibility + .radius(6.0), + ); + } + } + self.last_info.0 = if area.iter().any(|e| e.is_some()) { Some(format!("Area: {}", option_vec_printer(area.as_slice()))) } else { diff --git a/src/misc.rs b/src/misc.rs index 0b1339c..40a87d2 100644 --- a/src/misc.rs +++ b/src/misc.rs @@ -214,3 +214,47 @@ include!(concat!(env!("OUT_DIR"), "/valid_chars.rs")); pub fn is_valid_char(c: char) -> bool { c.is_alphanumeric() | VALID_EXTRA_CHARS.contains(&c) } + +/// Find intersection points between two functions given their plotted data +/// Returns a vector of PlotPoints where the functions intersect +pub fn find_intersections(data1: &[PlotPoint], data2: &[PlotPoint]) -> Vec { + if data1.is_empty() || data2.is_empty() || data1.len() != data2.len() { + return Vec::new(); + } + + // Calculate difference between functions at each x point + let differences: Vec<(f64, f64)> = data1 + .iter() + .zip(data2.iter()) + .filter(|(p1, p2)| p1.y.is_finite() && p2.y.is_finite()) + .map(|(p1, p2)| (p1.x, p1.y - p2.y)) + .collect(); + + // Find where sign changes (intersection points) + differences + .iter() + .tuple_windows() + .filter(|((_, diff1), (_, diff2))| diff1.signum() != diff2.signum()) + .map(|((x1, diff1), (x2, diff2))| { + // Linear interpolation to find approximate x of intersection + let t = diff1.abs() / (diff1.abs() + diff2.abs()); + let x = x1 + t * (x2 - x1); + + // Find corresponding y values and average them for the intersection point + // We need to interpolate y values from both functions + let y1_at_x1 = data1 + .iter() + .find(|p| (p.x - x1).abs() < f64::EPSILON) + .map(|p| p.y) + .unwrap_or(0.0); + let y1_at_x2 = data1 + .iter() + .find(|p| (p.x - x2).abs() < f64::EPSILON) + .map(|p| p.y) + .unwrap_or(0.0); + let y = y1_at_x1 + t * (y1_at_x2 - y1_at_x1); + + PlotPoint::new(x, y) + }) + .collect() +} diff --git a/tests/function.rs b/tests/function.rs index f5912ba..a88d4a5 100644 --- a/tests/function.rs +++ b/tests/function.rs @@ -19,6 +19,7 @@ fn app_settings_constructor( integral_num, do_extrema: false, do_roots: false, + do_intersections: false, plot_width: pixel_width, } }