diff --git a/crates/engine/src/cursor_tests.rs b/crates/engine/src/cursor_tests.rs index 7a9e0621..8d8f245e 100644 --- a/crates/engine/src/cursor_tests.rs +++ b/crates/engine/src/cursor_tests.rs @@ -270,13 +270,14 @@ fn vector_edit_mode_shows_each_tool_and_modifier() { let mut s = Session::new(); let path = id(&ex(&mut s, "vector.create", json!({"points": [[100, 100], [300, 100], [300, 300]], "closed": false}))); ex(&mut s, "vector.edit.begin", json!({"id": path})); - let (point, segment, empty) = ((300.0, 100.0), (200.0, 100.0), (200.0, 250.0)); + let (point, segment, midpoint, empty) = ((300.0, 100.0), (150.0, 100.0), (200.0, 100.0), (200.0, 250.0)); use Cursor::*; // (vector tool, modifiers, where, cursor, why) type Row = (&'static str, Mods, (f64, f64), Cursor, &'static str); let rows: Vec = vec![ ("move", NONE, point, Default, "the arrow over a point"), ("move", NONE, segment, Default, "the arrow over a segment"), + ("move", NONE, midpoint, Pen(PenCursor::Add), "the arrow over a segment's midpoint: adds a point there"), ("move", NONE, empty, Default, "the arrow elsewhere"), ("move", ALT, segment, Pen(PenCursor::Add), "Alt over a segment adds a point there"), ("move", ALT, point, Default, "Alt over a point adds nothing"), diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index 187e28d4..e35b054e 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -285,6 +285,9 @@ pub struct Overlay { pub vector_hover: Option, /// The edited layer `vector_hover` is on (several layers can be in edit mode). pub vector_hover_on: Option, + /// Vector edit mode: the point a press on the outline would add (the pen's ghost anchor where + /// it is over a segment, or the midpoint of the segment under the Move tool). + pub vector_add: Option, /// Vector edit mode, Variable width tool: the width point, handle or path under the pointer. pub width_hover: Option, pub lasso: Vec<[f64; 2]>, @@ -309,6 +312,16 @@ pub struct Overlay { pub shape: Option, } +/// A point a press would add to the edited vector's outline: page position, whether it is a +/// segment's midpoint (the Move tool) rather than the pen's place on its segment, and whether the +/// pointer is on it (a press adds it; otherwise it just shows where one would go). +#[derive(Clone, Copy, Debug, PartialEq, Serialize)] +pub struct VectorAdd { + pub at: [f64; 2], + pub midpoint: bool, + pub on: bool, +} + /// The Scale tool's live readout while dragging a handle. #[derive(Clone, Copy, Debug, PartialEq, Serialize)] pub struct ScaleHint { diff --git a/crates/engine/src/vector_behaviour_tests.rs b/crates/engine/src/vector_behaviour_tests.rs index fb734085..230d2998 100644 --- a/crates/engine/src/vector_behaviour_tests.rs +++ b/crates/engine/src/vector_behaviour_tests.rs @@ -236,7 +236,7 @@ fn selecting_points_and_segments() { match what { "click tl" => click(&mut s, (0.0, 0.0), m), "click tr" => click(&mut s, (100.0, 0.0), m), - "click top" => click(&mut s, (50.0, 0.0), m), + "click top" => click(&mut s, (30.0, 0.0), m), "marquee top" => drag(&mut s, (-20.0, -20.0), (110.0, 10.0), m), _ => { ex(&mut s, "vector.edit.tool", json!({"tool": "lasso"})); @@ -310,7 +310,7 @@ fn deleting_points_vs_delete_and_heal() { // Deleting a selected segment keeps its points when others use them. let mut s = Session::new(); let id = square(&mut s, true); - click(&mut s, (50.0, 0.0), NONE); + click(&mut s, (30.0, 0.0), NONE); key(&mut s, "Delete", false); let n = net(&s, id); assert_eq!((n.vertices.len(), n.segments.len()), (4, 3), "deleting a segment leaves its points"); diff --git a/crates/engine/src/vectoredit/pointer.rs b/crates/engine/src/vectoredit/pointer.rs index 6667a209..1684bf26 100644 --- a/crates/engine/src/vectoredit/pointer.rs +++ b/crates/engine/src/vectoredit/pointer.rs @@ -114,6 +114,16 @@ pub(crate) struct PointSnap { /// Eraser brush radius in screen pixels. const ERASER_PX: f64 = 8.0; +/// A segment's midpoint is a point a press adds when the pointer is this close (screen pixels). +const MIDPOINT_PX: f64 = 6.0; + +/// The page point at `t` (0..1) along segment `seg` of vector `id`. +fn segment_point(doc: &Document, id: NodeId, seg: usize, t: f64) -> Option { + use figcraft_geom::kurbo::ParamCurve; + let c = network(doc, id)?.cubic(seg)?; + Some(doc.world_transform(id) * c.eval(t.clamp(0.0, 1.0))) +} + fn erase_all(net: &mut VectorNetwork, stamps: &[Point], radius: f64) -> bool { stamps.iter().fold(false, |acc, c| ops::erase(net, *c, radius) | acc) } @@ -337,9 +347,22 @@ impl Session { let shown = if width_tool { None } else { h.map(|h| (ve.id, h)).or_else(|| hit_any(&doc, ve, ev.p(), tol)) }; // A pen click on empty canvas lands where it snaps. let pen_at = if ve.tool == VTool::Pen && h.is_none() && !ev.shift { Some(self.snap_pen(ev, Some(ve.id), &[])?) } else { None }; + // A point a press on the outline would add: the pen's place under its pointer on a segment + // (its ghost anchor), or the midpoint of the segment under the Move tool. + let add = match (ve.tool, h) { + (VTool::Pen, Some(VHit::Segment { seg, t })) => { + segment_point(&doc, ve.id, seg, t).map(|at| crate::tools::VectorAdd { at: [at.x, at.y], midpoint: false, on: true }) + } + (VTool::Move, Some(VHit::Segment { seg, .. })) if !ev.cmd && !ev.alt => segment_point(&doc, ve.id, seg, 0.5).map(|at| { + let on = (at - ev.p()).hypot() * ev.zoom.max(1e-6) <= MIDPOINT_PX; + crate::tools::VectorAdd { at: [at.x, at.y], midpoint: true, on } + }), + _ => None, + }; let ov = &mut self.tool.overlay; ov.vector_hover = shown.map(|x| x.1); ov.vector_hover_on = shown.map(|x| x.0); + ov.vector_add = add; ov.width_hover = None; ov.face.clear(); ov.rubber = None; @@ -351,6 +374,8 @@ impl Session { (VTool::Move, Some(_)) if ev.cmd => Cursor::Pen(PenCursor::Bend), (VTool::Move, _) if ev.cmd => Cursor::Crosshair, (VTool::Move, Some(VHit::Segment { .. })) if ev.alt => Cursor::Pen(PenCursor::Add), + // On a segment's midpoint the arrow turns into the pen: a press adds a point there. + (VTool::Move, Some(VHit::Segment { .. })) if add.is_some_and(|a| a.on) => Cursor::Pen(PenCursor::Add), (VTool::Move, _) => Cursor::Default, (VTool::Pen, Some(VHit::Vertex { .. })) => Cursor::Pen(PenCursor::Close), (VTool::Pen, Some(VHit::Segment { .. })) => Cursor::Pen(PenCursor::Add), @@ -370,11 +395,13 @@ impl Session { { self.tool.overlay.cursor = crate::tools::handle_cursor(&b.corners(), handle); self.tool.overlay.vector_hover = None; + self.tool.overlay.vector_add = None; } else if shown.is_none_or(|(on, h)| on == ve.id && !matches!(h, VHit::Handle { .. })) { region = Self::region_under(&doc, ve, h, ev); if region.is_some() { self.tool.overlay.vector_hover = None; self.tool.overlay.vector_hover_on = None; + self.tool.overlay.vector_add = None; } } } @@ -510,6 +537,7 @@ impl Session { }; let h = hit(&doc, ve, ev.p(), tol); self.tool.overlay.vector_hover = None; + self.tool.overlay.vector_add = None; self.tool.overlay.face.clear(); self.tool.overlay.guides.clear(); let tool = if ev.cmd && ve.tool == VTool::Move { VTool::Bend } else { ve.tool }; @@ -626,8 +654,14 @@ impl Session { self.vgesture(g); } Some(VHit::Segment { seg, t }) => { - if ev.alt { + // A press on the midpoint the hover showed adds a point exactly there; Alt adds + // one anywhere else on the segment. + let midpoint = !ev.alt + && !ev.shift + && segment_point(&doc, id, seg, 0.5).is_some_and(|m| (m - ev.p()).hypot() * ev.zoom.max(1e-6) <= MIDPOINT_PX); + if ev.alt || midpoint { // Add a point on the segment and drag it. + let t = if midpoint { 0.5 } else { t }; self.begin_interaction("Add point")?; let mut v = None; self.preview(|doc, _| { diff --git a/crates/engine/src/vectoredit/tests.rs b/crates/engine/src/vectoredit/tests.rs index 0eba28e3..ff3c4cfa 100644 --- a/crates/engine/src/vectoredit/tests.rs +++ b/crates/engine/src/vectoredit/tests.rs @@ -171,7 +171,7 @@ fn lasso_marquee_and_segment_selection() { assert!(s.tool.overlay.lasso.is_empty()); // Move tool: clicking a segment selects it; shift adds another. ex(&mut s, "vector.edit.tool", json!({"tool": "move"})); - click(&mut s, 50.0, 100.0); + click(&mut s, 30.0, 100.0); assert_eq!(ve(&s).segments.len(), 1); assert!(ve(&s).vertices.is_empty()); let mut sh = ev(PointerKind::Down, 0.0, 50.0); @@ -371,3 +371,67 @@ fn eraser_strokes_cut_paths_in_one_step() { assert!(key(&mut s, "E", true, false, false).unwrap()); assert_eq!(ve(&s).tool, VTool::Eraser); } + +#[test] +fn a_segment_shows_its_midpoint_and_a_press_adds_a_point_there() { + let mut s = Session::new(); + let id = rect(&mut s); + // Near the top edge but off its midpoint: the hint shows where the point would go, but the + // pointer is not on it, so the arrow stays. + s.pointer(&ev(PointerKind::Hover, 30.0, 0.0)).unwrap(); + let add = s.tool.overlay.vector_add.expect("the segment's midpoint"); + assert!(add.midpoint && !add.on, "{add:?}"); + assert!((add.at[0] - 50.0).abs() < 1e-9 && add.at[1].abs() < 1e-9, "{add:?}"); + assert_eq!(s.tool.overlay.cursor, crate::tools::Cursor::Default); + // On the midpoint: the pen cursor, and a press adds a point there and selects it. + s.pointer(&ev(PointerKind::Hover, 50.0, 0.0)).unwrap(); + assert!(s.tool.overlay.vector_add.is_some_and(|a| a.on), "the pointer is on the midpoint"); + assert_eq!(s.tool.overlay.cursor, crate::tools::Cursor::Pen(crate::tools::PenCursor::Add)); + click(&mut s, 50.0, 0.0); + let n = net(&s, id); + assert_eq!(n.vertices.len(), 5, "a point was added: {n:?}"); + assert_eq!(ve(&s).vertices, vec![vertex_at(&n, 50.0, 0.0)], "the new point is selected"); +} + +#[test] +fn adding_a_midpoint_keeps_new_midpoints_coming_on_each_half() { + let mut s = Session::new(); + let id = rect(&mut s); + click(&mut s, 50.0, 0.0); + assert_eq!(net(&s, id).vertices.len(), 5); + // The left half has its own midpoint, shown and added the same way. + s.pointer(&ev(PointerKind::Hover, 25.0, 0.0)).unwrap(); + let add = s.tool.overlay.vector_add.expect("the new half's midpoint"); + assert!(add.midpoint && add.on && (add.at[0] - 25.0).abs() < 1e-9, "{add:?}"); + click(&mut s, 25.0, 0.0); + let n = net(&s, id); + assert_eq!(n.vertices.len(), 6, "{n:?}"); + assert_eq!(ve(&s).vertices, vec![vertex_at(&n, 25.0, 0.0)]); + // And on it goes: a quarter of the way along. + s.pointer(&ev(PointerKind::Hover, 12.5, 0.0)).unwrap(); + let add = s.tool.overlay.vector_add.expect("another midpoint"); + assert!((add.at[0] - 12.5).abs() < 1e-9, "{add:?}"); +} + +#[test] +fn a_press_away_from_the_midpoint_selects_the_segment() { + let mut s = Session::new(); + let id = rect(&mut s); + click(&mut s, 30.0, 0.0); + assert_eq!(net(&s, id).vertices.len(), 4, "no point is added off the midpoint"); + assert_eq!(ve(&s).segments.len(), 1, "the segment is selected instead"); +} + +#[test] +fn the_pen_shows_its_ghost_anchor_on_the_segment_under_it() { + let mut s = Session::new(); + let id = rect(&mut s); + ex(&mut s, "vector.edit.tool", json!({"tool": "pen"})); + s.pointer(&ev(PointerKind::Hover, 80.0, 0.0)).unwrap(); + let add = s.tool.overlay.vector_add.expect("the ghost anchor"); + assert!(!add.midpoint && add.on, "{add:?}"); + assert!((add.at[0] - 80.0).abs() < 1e-3 && add.at[1].abs() < 1e-3, "{add:?}"); + click(&mut s, 80.0, 0.0); + assert_eq!(net(&s, id).vertices.len(), 5, "the press added the point"); + assert_eq!(ve(&s).anchor, None, "the click ended the pen's path"); +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 8126e6f3..841c95e8 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1021,10 +1021,10 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec<(NodeId, [ let hover_on = |id| ov.vector_hover.filter(|_| ov.vector_hover_on.unwrap_or(ve.id) == id); for part in &ve.others { if let Some(po) = figcraft_engine::vectoredit::overlay(&doc, &ve.view(part)) { - vector_overlay(app, painter, &po, !width_tool, hover_on(part.id), false); + vector_overlay(app, painter, &po, !width_tool, hover_on(part.id), false, None); } } - vector_overlay(app, painter, &o, !width_tool, hover_on(ve.id), true); + vector_overlay(app, painter, &o, !width_tool, hover_on(ve.id), true, ov.vector_add); if width_tool { width_overlay(app, painter, &doc, &ve, zoom); } else if let Some(c) = app.session.vector_edit_box() { @@ -1327,8 +1327,9 @@ fn bezier_handle(painter: &egui::Painter, v: Pos2, e: Pos2, r: f32, fill: Color3 painter.circle_stroke(e, r, stroke(accent(painter), 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. +/// Vector edit mode: a layer's segments, handles and points (`points`) with its `hover`ed part, the +/// `add` point a press would put on the outline, and for the `active` layer the closed area under +/// the paint bucket and the pen's rubber band. fn vector_overlay( app: &FigApp, painter: &egui::Painter, @@ -1336,6 +1337,7 @@ fn vector_overlay( points: bool, hover: Option, active: bool, + add: Option, ) { use figcraft_engine::vectoredit::VHit; let ov = &app.session.tool.overlay; @@ -1383,6 +1385,22 @@ fn vector_overlay( painter.circle_stroke(p, 7.5, stroke(accent(painter), 1.0)); } } + // The point a press would add to the outline: the pen's ghost anchor where it is over a + // segment, or a segment's midpoint under the Move tool (which grows and fills once the pointer + // is on it, when a press adds it). + if let Some(a) = add { + let p = app.to_screen(a.at[0], a.at[1]); + let col = accent(painter); + if a.midpoint { + let (r, fill) = if a.on { (5.5, col) } else { (3.5, Color32::from_white_alpha(190)) }; + painter.circle_filled(p, r, fill); + painter.circle_stroke(p, r, stroke(if a.on { Color32::WHITE } else { col.gamma_multiply(0.7) }, 1.2)); + } else { + let r = Rect::from_center_size(p, vec2(7.0, 7.0)); + painter.rect_filled(r, CornerRadius::same(5), Color32::from_white_alpha(160)); + painter.rect_stroke(r, CornerRadius::same(5), stroke(col.gamma_multiply(0.8), 1.0), StrokeKind::Inside); + } + } if let Some([x0, y0, x1, y1]) = ov.rubber.filter(|_| active) { painter.add(Shape::dashed_line(&[app.to_screen(x0, y0), app.to_screen(x1, y1)], stroke(accent(painter), 1.0), 4.0, 3.0)); }