From 58aad8dbab465a12c33545d25ac7ab6f1b68982b Mon Sep 17 00:00:00 2001 From: Bradlee Speice Date: Sat, 22 Mar 2025 15:03:39 -0400 Subject: [PATCH] Work in screen space coordinates, and offset circle drawing to improve hover detection --- crates/flare/src/gui/transform_editor.rs | 454 ++++++++++++----------- 1 file changed, 237 insertions(+), 217 deletions(-) diff --git a/crates/flare/src/gui/transform_editor.rs b/crates/flare/src/gui/transform_editor.rs index d9debcb..bed65f4 100644 --- a/crates/flare/src/gui/transform_editor.rs +++ b/crates/flare/src/gui/transform_editor.rs @@ -1,94 +1,16 @@ use egui::emath::RectTransform; use egui::*; use flare_shader::Coefs; +use std::ops::Add; -const HANDLE_RADIUS_IFS: f32 = 0.04; -const HANDLE_RADIUS_DRAW_IFS: f32 = HANDLE_RADIUS_IFS / 2.0; +/// Radius (in pixels) of the transform element draw circle +const ELEMENT_DRAW_RADIUS_PX: f32 = 7.0; -#[derive(Copy, Clone, Debug, PartialEq)] -pub enum TransformElement { - Origin, - X, - Y, -} +/// Stroke size (in pixels) of the transform element draw circle +const ELEMENT_DRAW_STROKE_PX: f32 = 2.0; -/// Affine coefficients expressed as three points of a triangle -#[derive(Copy, Clone, Debug, PartialEq)] -pub struct CoefsTriangle { - origin: Pos2, - x: Pos2, - y: Pos2, -} - -impl CoefsTriangle { - pub fn new(origin: Pos2, x: Pos2, y: Pos2) -> Self { - Self { origin, x, y } - } -} - -impl From for CoefsTriangle { - fn from(value: Coefs) -> Self { - let origin = pos2(value.c, -value.f); - Self { - origin, - x: origin + vec2(value.a, -value.d), - y: origin + vec2(-value.b, value.e), - } - } -} - -impl Into for CoefsTriangle { - fn into(self) -> Coefs { - Coefs { - a: self.x.x - self.origin.x, - b: self.origin.x - self.y.x, - c: self.origin.x, - d: self.origin.y - self.x.y, - e: self.y.y - self.origin.y, - f: -self.origin.y, - } - } -} - -/// Widget for manipulating IFS transform affine coefficients -#[derive(Copy, Clone, Debug)] -pub struct TransformEditor { - /// Center point (in IFS coordinates) of the editor window - center_ifs: Pos2, - - /// Total range (in IFS coordinates) of the editor window - range_ifs: f32, - - /// Hover position (in IFS coordinates) of the cursor. - /// - /// Because of input latency during large movements, `egui`'s drag motion - /// doesn't precisely match the prior cursor position. Track the position - /// here to calculate an exact update from the last frame to now. - hover_pos_ifs: Option, - - /// Index of the transform the cursor is hovering over - hover_index: Option, - - /// For the hovered transform, which specific element the cursor is hovering over. - /// Assumed to always have a value when `hover_index` has a value - hover_element: Option, - - /// Index of the transform being dragged - drag_index: Option, -} - -impl Default for TransformEditor { - fn default() -> Self { - Self { - center_ifs: Pos2::ZERO, - range_ifs: 4.0, - hover_pos_ifs: None, - hover_index: None, - hover_element: None, - drag_index: None, - } - } -} +/// Offset (in pixels) of the transform element draw circle +const ELEMENT_DRAW_OFFSET_PX: Vec2 = vec2(-2.0, -2.0); fn test_in_circle(pt: Pos2, center: Pos2, radius: f32) -> bool { ((pt.x - center.x).powf(2.0) + (pt.y - center.y).powf(2.0)) <= radius.powf(2.0) @@ -110,36 +32,145 @@ fn test_in_triangle(pt: Pos2, v1: Pos2, v2: Pos2, v3: Pos2) -> bool { !(has_neg && has_pos) } -/// Test whether the provided position is hovering on the transform -fn test_hovered(hover_pos: Option, coefs: Coefs) -> Option { - let coefs_triangle: CoefsTriangle = coefs.into(); +#[derive(Copy, Clone, Debug, PartialEq)] +pub enum TransformElement { + Origin, + X, + Y, +} - if hover_pos.is_none() { - return None; +/// Affine coefficients expressed as three points of a triangle +#[derive(Copy, Clone, Debug, PartialEq)] +pub struct TransformTriangle { + origin: Pos2, + x: Pos2, + y: Pos2, +} + +impl TransformTriangle { + pub fn new(origin: Pos2, x: Pos2, y: Pos2) -> Self { + Self { origin, x, y } } - let hover_pos = hover_pos.unwrap(); - if test_in_circle(hover_pos, coefs_triangle.x, HANDLE_RADIUS_IFS) { - Some(TransformElement::X) - } else if test_in_circle(hover_pos, coefs_triangle.y, HANDLE_RADIUS_IFS) { - Some(TransformElement::Y) - } else if test_in_circle(hover_pos, coefs_triangle.origin, HANDLE_RADIUS_IFS) - || test_in_triangle( - hover_pos, - coefs_triangle.origin, - coefs_triangle.x, - coefs_triangle.y, - ) - { - Some(TransformElement::Origin) - } else { - None + pub fn interact_drag(self, element: TransformElement, drag_delta: Vec2) -> Self { + match element { + TransformElement::X => Self::new(self.origin, self.x + drag_delta, self.y), + TransformElement::Y => Self::new(self.origin, self.x, self.y + drag_delta), + TransformElement::Origin => Self::new( + self.origin + drag_delta, + self.x + drag_delta, + self.y + drag_delta, + ), + } + } + + pub fn transform_pos(self, rect: RectTransform) -> Self { + Self { + origin: rect.transform_pos(self.origin), + x: rect.transform_pos(self.x), + y: rect.transform_pos(self.y), + } + } + + pub fn is_hovered( + &self, + hover_pos: Pos2, + ifs_to_screen: RectTransform, + ) -> Option { + let origin_pos = ifs_to_screen.transform_pos(self.origin); + let x_pos = ifs_to_screen.transform_pos(self.x); + let y_pos = ifs_to_screen.transform_pos(self.y); + + if test_in_circle(hover_pos, x_pos, ELEMENT_DRAW_RADIUS_PX) { + Some(TransformElement::X) + } else if test_in_circle(hover_pos, y_pos, ELEMENT_DRAW_RADIUS_PX) { + Some(TransformElement::Y) + } else if test_in_circle(hover_pos, origin_pos, ELEMENT_DRAW_RADIUS_PX) + || test_in_triangle(hover_pos, origin_pos, x_pos, y_pos) + { + Some(TransformElement::Origin) + } else { + None + } } } -fn build_viewport_ifs(interact_rect: Rect, center_ifs: Pos2, range_ifs: f32) -> Rect { - let aspect_ratio = interact_rect.width() / interact_rect.height(); +impl From for TransformTriangle { + fn from(value: Coefs) -> Self { + let origin = pos2(value.c, -value.f); + Self { + origin, + x: origin + vec2(value.a, -value.d), + y: origin + vec2(-value.b, value.e), + } + } +} +impl Into for TransformTriangle { + fn into(self) -> Coefs { + Coefs { + a: self.x.x - self.origin.x, + b: self.origin.x - self.y.x, + c: self.origin.x, + d: self.origin.y - self.x.y, + e: self.y.y - self.origin.y, + f: -self.origin.y, + } + } +} + +impl Add for TransformTriangle { + type Output = TransformTriangle; + + fn add(self, rhs: Vec2) -> Self::Output { + Self { + origin: self.origin + rhs, + x: self.x + rhs, + y: self.y + rhs, + } + } +} + +/// Widget for manipulating IFS transform affine coefficients +#[derive(Copy, Clone, Debug)] +pub struct TransformEditor { + /// Center point (in IFS coordinates) of the editor window + center_ifs: Pos2, + + /// Total range (in IFS coordinates) of the editor window + range_ifs: f32, + + /// Hover position (in screen coordinates) of the cursor on the previous update. + /// + /// Because of input latency during large drag motions, `egui`'s drag motion + /// isn't precise enough to update transform coefficients. Instead, track the + /// cursor position directly + hover_pos: Option, + + /// Transform index the cursor is hovering over + hover_index: Option, + + /// Specific element of the transform hovered by the cursor + hover_element: Option, + + /// Transform index the cursor is dragging + drag_index: Option, +} + +impl Default for TransformEditor { + fn default() -> Self { + Self { + center_ifs: Pos2::ZERO, + range_ifs: 4.0, + hover_pos: None, + hover_index: None, + hover_element: None, + drag_index: None, + } + } +} + +fn build_viewport_ifs(aspect_ratio: f32, center_ifs: Pos2, range_ifs: f32) -> Rect { let size_ifs = if aspect_ratio >= 1.0 { vec2(range_ifs * aspect_ratio, range_ifs) } else { @@ -148,8 +179,8 @@ fn build_viewport_ifs(interact_rect: Rect, center_ifs: Pos2, range_ifs: f32) -> let min_ifs = center_ifs - size_ifs / 2.0; let max_ifs = center_ifs + size_ifs / 2.0; - // IFS coordinates follow the screen coordinate "value increases from top left to bottom right" - // convention, so the Y-axis is flipped here to behave like a Cartesian plot + // IFS coordinates follow the "value increases from top left to bottom right" convention. + // Because we want coordinates to behave like a Cartesian plot, the Y-axis is flipped Rect::from_min_max(pos2(min_ifs.x, max_ifs.y), pos2(max_ifs.x, min_ifs.y)) } @@ -163,145 +194,148 @@ impl TransformEditor { } let interact_rect = response.interact_rect; - let ifs_rect = build_viewport_ifs(interact_rect, self.center_ifs, self.range_ifs); - let pixels_per_unit_ifs = interact_rect.width() / ifs_rect.width(); + let ifs_rect = build_viewport_ifs( + interact_rect.aspect_ratio(), + self.center_ifs, + self.range_ifs, + ); // Update internal state based on screen interactions, then paint to screen - let to_ifs = RectTransform::from_to(interact_rect, ifs_rect); - let hover_pos_ifs = response.hover_pos().map(|p| to_ifs.transform_pos(p)); - let focus_index = self.interact_update( - hover_pos_ifs, - response.clicked(), - response.drag_started(), - response.drag_stopped(), + let ifs_to_screen = RectTransform::from_to(ifs_rect, interact_rect); + self.interact_update( + ui.input(|i| i.pointer.interact_pos()), + ui.input(|i| i.pointer.primary_pressed()), + ui.input(|i| i.pointer.primary_released()), + ifs_to_screen, transforms, ); - let to_screen = RectTransform::from_to(ifs_rect, interact_rect); - self.interact_draw(painter, to_screen, pixels_per_unit_ifs, transforms); + self.interact_draw(painter, ifs_to_screen, transforms); - focus_index + self.drag_index } - /// Update state of the provided transform coefficients based on current interactions. - /// - /// Assumes that positions/vectors are in IFS coordinates + /// Update state of the provided transform coefficients based on current interactions, + /// return the transform coefficients that have claimed focus (if any) fn interact_update( &mut self, hover_pos: Option, - clicked: bool, - drag_started: bool, - drag_stopped: bool, + primary_pressed: bool, + primary_released: bool, + ifs_to_screen: RectTransform, transforms: &mut [Coefs], - ) -> Option { - // Check each transform to see if it is hovered (giving priority to the currently hovered transform), - let mut hover_found = false; - if self.hover_index.is_some_and(|i| i < transforms.len()) { - let hover_element = - test_hovered(self.hover_pos_ifs, transforms[self.hover_index.unwrap()]); - if hover_element.is_some() { - hover_found = true; - self.hover_element = hover_element; - } + ) { + // If the cursor is not in this widget, reset state + if hover_pos.is_none() { + self.hover_pos = None; + self.hover_index = None; + self.hover_element = None; + self.drag_index = None; + return; } + let hover_pos = hover_pos.unwrap(); + + // If the transform array was modified, reset state and then proceed + if self.hover_index.map_or(false, |i| i >= transforms.len()) + || self.drag_index.map_or(false, |i| i >= transforms.len()) + { + self.hover_index = None; + self.hover_element = None; + self.drag_index = None; + } + + // If a transform is being dragged, update its position + if self.drag_index.is_some() { + let last_hover_pos = self.hover_pos.unwrap(); + let hover_index = self.hover_index.unwrap(); + let hover_element = self.hover_element.unwrap(); + + let drag_delta_ifs = (hover_pos - last_hover_pos) / ifs_to_screen.scale(); + let transform_triangle: TransformTriangle = transforms[hover_index].into(); + transforms[hover_index] = transform_triangle + .interact_drag(hover_element, drag_delta_ifs) + .into(); + } + + // Store the hover pos for use on the next update + self.hover_pos = Some(hover_pos); + + // Check if the currently-hovered transform is still hovered + let mut hover_found = false; + + if let Some(hover_index) = self.hover_index { + let transform_triangle: TransformTriangle = transforms[hover_index].into(); + self.hover_element = transform_triangle.is_hovered(hover_pos, ifs_to_screen); + hover_found = self.hover_element.is_some(); + } + + // Check if any transform is hovered if !hover_found { for (i, transform) in transforms.iter().enumerate() { - if let Some(hover_element) = test_hovered(self.hover_pos_ifs, *transform) { + let transform_triangle: TransformTriangle = (*transform).into(); + self.hover_element = transform_triangle.is_hovered(hover_pos, ifs_to_screen); + + if self.hover_element.is_some() { hover_found = true; self.hover_index = Some(i); - self.hover_element = Some(hover_element); break; } } } if !hover_found { - // No transforms are hovered, clear interaction state and return - self.hover_pos_ifs = hover_pos; + // No hovers found, reset state self.hover_index = None; self.hover_element = None; self.drag_index = None; - return None; } - let hover_delta = - if let (Some(current_hover_pos), Some(hover_pos)) = (hover_pos, self.hover_pos_ifs) { - current_hover_pos - hover_pos - } else { - Vec2::ZERO - }; - self.hover_pos_ifs = hover_pos; - - // If the hovered transform is clicked, it receives focus and we end updates - if clicked { - return self.hover_index; - } - - // If the hovered transform is dragged, it receives focus and the drag index is updated - if drag_started { + // Check drag state + if primary_pressed && self.hover_index.is_some() { self.drag_index = self.hover_index; } - - // If there is a transform being dragged, update its position - if self.drag_index.is_some() && hover_delta.abs().max_elem() > 0.0 { - let mut coefs = &mut transforms[self.drag_index.unwrap()]; - let mut coefs_triangle: CoefsTriangle = (*coefs).into(); - - match self.hover_element.unwrap() { - TransformElement::X => coefs_triangle.x += hover_delta, - TransformElement::Y => coefs_triangle.y += hover_delta, - TransformElement::Origin => { - coefs_triangle.x += hover_delta; - coefs_triangle.y += hover_delta; - coefs_triangle.origin += hover_delta; - } - } - - *coefs = coefs_triangle.into(); - } - - // If the transform is no longer being dragged, clear the drag index - if drag_stopped { + if primary_released { self.drag_index = None; } - - self.drag_index } fn interact_draw_transform( + hover_element: Option, painter: &Painter, - to_screen: RectTransform, - pixels_per_unit_ifs: f32, + ifs_to_screen: RectTransform, transform: Coefs, - hovered_element: Option, ) { - let coefs_triangle: CoefsTriangle = transform.into(); - let origin_screen = to_screen.transform_pos(coefs_triangle.origin); - let x_screen = to_screen.transform_pos(coefs_triangle.x); - let y_screen = to_screen.transform_pos(coefs_triangle.y); + // `epaint` doesn't provide an option for whether the stroke is drawn inside, in the middle, + // or outside the shape to paint. In manual testing, hover detection works best when assuming + // the stroke is outside the shape. + // Also in manual testing, hover detection seems to work best when drawing the circle at + // a slight offset to the actual center position. Not clear why. + let transform_triangle: TransformTriangle = transform.into(); + let draw_triangle = + transform_triangle.transform_pos(ifs_to_screen) + ELEMENT_DRAW_OFFSET_PX; - let stroke = Stroke::new(2.0, Color32::BLUE); + let stroke = Stroke::new(ELEMENT_DRAW_STROKE_PX, Color32::BLUE); painter.circle_stroke( - origin_screen, - HANDLE_RADIUS_DRAW_IFS * pixels_per_unit_ifs, + draw_triangle.origin, + ELEMENT_DRAW_RADIUS_PX - ELEMENT_DRAW_STROKE_PX, stroke, ); painter.circle_stroke( - x_screen, - HANDLE_RADIUS_DRAW_IFS * pixels_per_unit_ifs, + draw_triangle.x, + ELEMENT_DRAW_RADIUS_PX - ELEMENT_DRAW_STROKE_PX, stroke, ); painter.circle_stroke( - y_screen, - HANDLE_RADIUS_DRAW_IFS * pixels_per_unit_ifs, + draw_triangle.y, + ELEMENT_DRAW_RADIUS_PX - ELEMENT_DRAW_STROKE_PX, stroke, ); - let body_alpha: u8 = if hovered_element.is_some() { 8 } else { 0 }; + let body_alpha: u8 = if hover_element.is_some() { 8 } else { 0 }; let body_fill = Color32::from_rgba_unmultiplied(0, 0, u8::MAX, body_alpha); let body = Shape::convex_polygon( - vec![origin_screen, x_screen, y_screen], + vec![draw_triangle.origin, draw_triangle.x, draw_triangle.y], body_fill, stroke, ); @@ -309,35 +343,21 @@ impl TransformEditor { } /// Draw the provided transform coefficients to the screen. - fn interact_draw( - &self, - painter: Painter, - to_screen: RectTransform, - pixels_per_unit_ifs: f32, - transforms: &[Coefs], - ) { - // Hovered transform is painted at the end so it has priority + fn interact_draw(&self, painter: Painter, ifs_to_screen: RectTransform, transforms: &[Coefs]) { transforms .iter() .enumerate() .filter(|(i, _)| Some(*i) != self.hover_index) .for_each(|(_, transform)| { - TransformEditor::interact_draw_transform( - &painter, - to_screen, - pixels_per_unit_ifs, - *transform, - None - ) + Self::interact_draw_transform(None, &painter, ifs_to_screen, *transform) }); self.hover_index.map(|i| { - TransformEditor::interact_draw_transform( - &painter, - to_screen, - pixels_per_unit_ifs, - transforms[i], + Self::interact_draw_transform( self.hover_element, + &painter, + ifs_to_screen, + transforms[i], ) }); }