diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 0284075d..a85db683 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1527,6 +1527,8 @@ mod panels_behaviour_tests; #[cfg(test)] mod pdf_print_tests; #[cfg(test)] +mod pen_overlay_tests; +#[cfg(test)] mod pixel_preview_tests; #[cfg(test)] mod playground_tests; diff --git a/crates/engine/src/pen_overlay_tests.rs b/crates/engine/src/pen_overlay_tests.rs new file mode 100644 index 00000000..7e0064d2 --- /dev/null +++ b/crates/engine/src/pen_overlay_tests.rs @@ -0,0 +1,65 @@ +//! What the Pen tool shows while a path is drawn (`tool.overlay`): the handles shaping the segments +//! at the pen, and the rubber band to the pointer, which follows the last anchor's out handle. + +use crate::Session; +use crate::tools::{PointerEvent, PointerKind, ToolId}; + +fn at(s: &mut Session, kind: PointerKind, x: f64, y: f64) { + s.pointer(&PointerEvent::at(kind, x, y, 1.0)).unwrap(); +} + +/// A press at (x, y) dragged to (to_x, to_y) and released: a smooth point with out handle +/// (to - p). +fn drag_point(s: &mut Session, x: f64, y: f64, to_x: f64, to_y: f64) { + at(s, PointerKind::Down, x, y); + at(s, PointerKind::Move, to_x, to_y); + at(s, PointerKind::Up, to_x, to_y); +} + +#[test] +fn the_dragged_point_shows_its_handles_and_the_one_before_its_out_handle() { + let mut s = Session::new(); + s.set_tool(ToolId::Pen); + drag_point(&mut s, 100.0, 100.0, 140.0, 100.0); + assert_eq!(s.tool.overlay.pen_handles, vec![[100.0, 100.0, 60.0, 100.0], [100.0, 100.0, 140.0, 100.0]], "in and out of the first point"); + // The second point, mid-drag. + at(&mut s, PointerKind::Down, 200.0, 200.0); + assert_eq!(s.tool.overlay.rubber, None, "the press shows the segment itself, not a straight band"); + at(&mut s, PointerKind::Move, 200.0, 240.0); + assert_eq!( + s.tool.overlay.pen_handles, + vec![[100.0, 100.0, 140.0, 100.0], [200.0, 200.0, 200.0, 160.0], [200.0, 200.0, 200.0, 240.0]], + "the one before's out handle, then the dragged point's in and out" + ); + at(&mut s, PointerKind::Up, 200.0, 240.0); + // Moving on: the band leaves along the last out handle. + at(&mut s, PointerKind::Hover, 300.0, 200.0); + assert_eq!(s.tool.overlay.rubber, Some([200.0, 200.0, 300.0, 200.0])); + assert_eq!(s.tool.overlay.rubber_handle, Some([200.0, 240.0])); +} + +#[test] +fn corner_points_have_no_handles_and_a_straight_band() { + let mut s = Session::new(); + s.set_tool(ToolId::Pen); + for (x, y) in [(100.0, 100.0), (200.0, 100.0)] { + at(&mut s, PointerKind::Down, x, y); + at(&mut s, PointerKind::Up, x, y); + } + at(&mut s, PointerKind::Hover, 250.0, 200.0); + assert!(s.tool.overlay.pen_handles.is_empty()); + assert_eq!(s.tool.overlay.rubber, Some([200.0, 100.0, 250.0, 200.0])); + assert_eq!(s.tool.overlay.rubber_handle, None, "straight"); +} + +#[test] +fn finishing_the_path_clears_its_handles_and_band() { + let mut s = Session::new(); + s.set_tool(ToolId::Pen); + drag_point(&mut s, 100.0, 100.0, 140.0, 100.0); + drag_point(&mut s, 200.0, 200.0, 200.0, 240.0); + at(&mut s, PointerKind::Hover, 300.0, 200.0); + s.pen_finish().unwrap(); + let ov = &s.tool.overlay; + assert!(ov.pen.is_empty() && ov.pen_handles.is_empty() && ov.rubber.is_none() && ov.rubber_handle.is_none()); +} diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index aec91aec..ddd276f9 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -266,6 +266,12 @@ pub struct Overlay { /// Pen tool: the in-progress path's anchor points and the rubber band to the pointer. pub pen: Vec<[f64; 2]>, pub rubber: Option<[f64; 4]>, + /// Pen tool: the handles shaping the segments at the pen (anchor x, y, handle x, y): the last + /// anchor's in and out handles and the one before's out handle. + pub pen_handles: Vec<[f64; 4]>, + /// Pen tool: where the last anchor's out handle ends, when it has one: the rubber band is the + /// curve a click would make, leaving along it. + pub rubber_handle: Option<[f64; 2]>, /// Frame/section the pointer is drawing or dropping into (highlighted). pub drop_target: Option, /// Auto layout insertion indicator (x0, y0, x1, y1). @@ -397,6 +403,14 @@ pub(crate) enum Gesture { }, } +/// The handles a pen path shows while it is drawn ([`Overlay::pen_handles`]): the last anchor's in +/// and out handles and the one before's out handle, those that aren't zero. +fn pen_handles(points: &[(Point, Vec2, Vec2)]) -> Vec<[f64; 4]> { + let last = points.last().into_iter().flat_map(|(p, i, o)| [(p, i), (p, o)]); + let before = points.len().checked_sub(2).and_then(|k| points.get(k)).into_iter().map(|(p, _, o)| (p, o)); + before.chain(last).filter(|(_, h)| h.hypot() > 0.0).map(|(p, h)| [p.x, p.y, p.x + h.x, p.y + h.y]).collect() +} + /// The pen tool's in-progress path (page coordinates). #[derive(Clone, Debug, Default)] pub struct PenPath { @@ -1057,8 +1071,9 @@ impl Session { let (to, g) = if ev.shift { (ev.p(), Vec::new()) } else { self.snap_pen_tool(ev)? }; guides = g; self.tool.overlay.cursor = Cursor::Pen(self.pen_cursor(&doc, &sel, to, ev.zoom)); - if let Some((last, _, _)) = self.tool.pen.as_ref().and_then(|pen| pen.points.last()) { + if let Some((last, _, out)) = self.tool.pen.as_ref().and_then(|pen| pen.points.last()) { self.tool.overlay.rubber = Some([last.x, last.y, to.x, to.y]); + self.tool.overlay.rubber_handle = (out.hypot() > 0.0).then_some([last.x + out.x, last.y + out.y]); } } self.tool.overlay.guides = guides; @@ -1974,6 +1989,9 @@ impl Session { fn pen_down(&mut self, ev: &PointerEvent) -> Result<()> { self.tool.overlay.guides.clear(); + // The segment being placed shows as itself while the press lasts. + self.tool.overlay.rubber = None; + self.tool.overlay.rubber_handle = None; let p = if ev.shift { ev.p() } else { self.snap_pen_tool(ev)?.0 }; let (doc, page) = { let ds = self.ds()?; @@ -2028,6 +2046,7 @@ impl Session { fn pen_update(&mut self) -> Result<()> { let Some(pen) = self.tool.pen.clone() else { return Ok(()) }; self.tool.overlay.pen = pen.points.iter().map(|(p, _, _)| [p.x, p.y]).collect(); + self.tool.overlay.pen_handles = pen_handles(&pen.points); if pen.points.len() < 2 { if let Ok(ds) = self.ds_mut() { ds.revision += 1; @@ -2110,7 +2129,9 @@ impl Session { pub fn pen_finish(&mut self) -> Result<()> { let pen = self.tool.pen.take(); self.tool.overlay.pen.clear(); + self.tool.overlay.pen_handles.clear(); self.tool.overlay.rubber = None; + self.tool.overlay.rubber_handle = None; self.tool.gesture = Gesture::None; if pen.as_ref().is_some_and(|p| p.id.is_some()) { self.commit_interaction()?; diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index eb23c8de..beec9aa3 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -973,12 +973,30 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec<(NodeId, [ if app.session.tool.tool == ToolId::Pencil { painter.add(Shape::line(pts, stroke(t.text, 2.0))); } else { + for [x0, y0, x1, y1] in &ov.pen_handles { + bezier_handle(painter, app.to_screen(*x0, *y0), app.to_screen(*x1, *y1), 3.5, Color32::WHITE); + } for p in &pts { painter.circle_filled(*p, 3.5, Color32::WHITE); painter.circle_stroke(*p, 3.5, stroke(ACCENT, 1.0)); } if let Some([x0, y0, x1, y1]) = ov.rubber { - painter.line_segment([app.to_screen(x0, y0), app.to_screen(x1, y1)], stroke(ACCENT, 1.0)); + let (a, b) = (app.to_screen(x0, y0), app.to_screen(x1, y1)); + // The segment a click would make: along the last anchor's out handle, if it has one. + match ov.rubber_handle { + Some([hx, hy]) => { + let h = app.to_screen(hx, hy); + painter.add(egui::epaint::CubicBezierShape::from_points_stroke( + [a, h, b, b], + false, + Color32::TRANSPARENT, + stroke(ACCENT, 1.0), + )); + } + None => { + painter.line_segment([a, b], stroke(ACCENT, 1.0)); + } + } } } } @@ -1111,6 +1129,14 @@ fn width_overlay(app: &FigApp, painter: &egui::Painter, doc: &Document, ve: &fig } } +/// A bezier handle from its anchor `v` to its end `e` (screen points): the line, and a dot of +/// radius `r` filled with `fill` (vector edit mode and the Pen draw them alike). +fn bezier_handle(painter: &egui::Painter, v: Pos2, e: Pos2, r: f32, fill: Color32) { + painter.line_segment([v, e], stroke(ACCENT, 1.0)); + painter.circle_filled(e, r, fill); + painter.circle_stroke(e, r, stroke(ACCENT, 1.0)); +} + /// Vector edit mode: a layer's segments, handles and points (`points`) with its `hover`ed part, and /// for the `active` layer the closed area under the paint bucket and the pen's rubber band. fn vector_overlay( @@ -1151,13 +1177,9 @@ fn vector_overlay( return; } for h in &o.handles { - let (v, e) = (sp(&h.vertex), sp(&h.handle)); - painter.line_segment([v, e], stroke(ACCENT, 1.0)); let hovered = hover == Some(VHit::Handle { seg: h.seg, start: h.start }); - let r = if hovered { 4.5 } else { 3.5 }; let on = o.selected_handles.contains(&(h.seg, h.start)); - painter.circle_filled(e, r, if on { ACCENT } else { Color32::WHITE }); - painter.circle_stroke(e, r, stroke(ACCENT, 1.0)); + bezier_handle(painter, sp(&h.vertex), sp(&h.handle), if hovered { 4.5 } else { 3.5 }, if on { ACCENT } else { Color32::WHITE }); } for (i, v) in o.vertices.iter().enumerate() { let p = sp(v); diff --git a/crates/ui-egui/src/canvas_fixes_tests.rs b/crates/ui-egui/src/canvas_fixes_tests.rs index 37b03e55..5a36b712 100644 --- a/crates/ui-egui/src/canvas_fixes_tests.rs +++ b/crates/ui-egui/src/canvas_fixes_tests.rs @@ -1,5 +1,6 @@ //! Canvas fixes, driven through egui headless: View ▸ Frame outlines and View ▸ Slices draw (off -//! by default), and the render worker counts a job as in flight until it is done. +//! by default), the render worker counts a job as in flight until it is done, and the Pen shows +//! the handles of the path it draws. use std::sync::Arc; use std::sync::atomic::{AtomicUsize, Ordering}; @@ -91,3 +92,36 @@ fn a_job_queued_behind_a_finished_one_stays_in_flight() { // Not in flight only once the second is done: its image is then waiting. assert!(worker.in_flight() || worker.poll().is_some(), "the second job counts until its image is there"); } + +#[test] +fn the_pen_draws_the_handles_of_the_point_being_dragged_and_a_curved_band() { + use egui::{Event, PointerButton}; + let mut u = Ui::new(Session::new()); + u.app.run("tool.set", json!({"tool": "pen"})); + let at = |u: &Ui, x: f64, y: f64| u.app.to_screen(x, y); + let press = |p: Pos2, pressed: bool| { + vec![Event::PointerMoved(p), Event::PointerButton { pos: p, button: PointerButton::Primary, pressed, modifiers: Default::default() }] + }; + let accent = egui::epaint::ColorMode::Solid(crate::theme::ACCENT); + // A handle: the line from its anchor and the dot at its end. + let handle = |shapes: &[Shape], a: Pos2, e: Pos2| { + let near = |p: Pos2, q: Pos2| p.distance(q) < 0.5; + let line = shapes.iter().any(|s| matches!(s, Shape::LineSegment { points: [p, q], .. } if near(*p, a) && near(*q, e))); + let dot = shapes.iter().any(|s| matches!(s, Shape::Circle(c) if near(c.center, e) && c.fill == egui::Color32::WHITE)); + line && dot + }; + let a = at(&u, 100.0, 100.0); + u.frame(press(a, true)); + for k in 1..=4 { + u.frame(vec![Event::PointerMoved(at(&u, 100.0 + 10.0 * f64::from(k), 100.0))]); + } + let shapes = flat(&u.frame_output(vec![]).shapes); + assert!(handle(&shapes, a, at(&u, 140.0, 100.0)), "the out handle while dragging"); + assert!(handle(&shapes, a, at(&u, 60.0, 100.0)), "and the in handle opposite"); + u.frame(press(at(&u, 140.0, 100.0), false)); + // After release, towards the next point: the band curves along the out handle. + let shapes = flat(&u.frame_output(vec![Event::PointerMoved(at(&u, 200.0, 200.0))]).shapes); + assert!(handle(&shapes, a, at(&u, 140.0, 100.0)), "the handles stay"); + let band = shapes.iter().any(|s| matches!(s, Shape::CubicBezier(c) if c.points[0].distance(a) < 0.5 && c.points[1].distance(at(&u, 140.0, 100.0)) < 0.5 && c.stroke.color == accent)); + assert!(band, "a curve leaving along the out handle"); +}