diff --git a/src/geometry.rs b/src/geometry.rs index 41cf62a..aff9f35 100644 --- a/src/geometry.rs +++ b/src/geometry.rs @@ -1499,6 +1499,77 @@ pub fn detect_axis_cross(points: &[Vec2]) -> Option { }) } +/// Achsenkreuz aus zwei Strichen: eine waagerechte und eine senkrechte Gerade (je höchstens +/// 25° schief, Reihenfolge egal), die sich kreuzen und auf beiden Seiten des Schnittpunkts +/// deutlich weiterlaufen. Ergebnis wie bei `detect_axis_cross`. +pub fn axis_cross_from_lines(a: (Vec2, Vec2), b: (Vec2, Vec2)) -> Option { + let max_slope = 25.0_f32.to_radians().tan(); + let is_horizontal = |(p, q): (Vec2, Vec2)| (q.y - p.y).abs() <= (q.x - p.x).abs() * max_slope; + let is_vertical = |(p, q): (Vec2, Vec2)| (q.x - p.x).abs() <= (q.y - p.y).abs() * max_slope; + let (horizontal, vertical) = if is_horizontal(a) && is_vertical(b) { + (a, b) + } else if is_vertical(a) && is_horizontal(b) { + (b, a) + } else { + return None; + }; + + let (x_min, x_max) = ( + horizontal.0.x.min(horizontal.1.x), + horizontal.0.x.max(horizontal.1.x), + ); + let (y_min, y_max) = ( + vertical.0.y.min(vertical.1.y), + vertical.0.y.max(vertical.1.y), + ); + let origin = Vec2::new( + (vertical.0.x + vertical.1.x) * 0.5, + (horizontal.0.y + horizontal.1.y) * 0.5, + ); + + // Vier Arme: jeder mindestens ein Fünftel seiner Achse, die Achsen nicht winzig + let (width, height) = (x_max - x_min, y_max - y_min); + let arm_ok = |arm: f32, axis: f32| arm >= axis * 0.2; + if width < 30.0 + || height < 30.0 + || !(arm_ok(origin.x - x_min, width) + && arm_ok(x_max - origin.x, width) + && arm_ok(origin.y - y_min, height) + && arm_ok(y_max - origin.y, height)) + { + return None; + } + + // x nach rechts, y nach oben (auf dem Blatt: kleineres y) + Some(RecognizedShape::CoordinateSystem { + origin, + x_axis_end: Vec2::new(x_max, origin.y), + y_axis_end: Vec2::new(origin.x, y_min), + with_arrows: true, + negative_ends: Some([Vec2::new(x_min, origin.y), Vec2::new(origin.x, y_max)]), + }) +} + +/// Gerade, die ein Strich darstellt: eine als Gerade eingerastete Form oder ein freihändiger +/// Strich, den die Formerkennung als Gerade erkennt. Bilder, Textfelder und Textmarker zählen +/// nicht. +pub fn stroke_as_line(stroke: &Stroke) -> Option<(Vec2, Vec2)> { + if stroke.image.is_some() + || stroke.text.is_some() + || stroke.tool == crate::document::StrokeTool::Highlighter + { + return None; + } + let shape = match &stroke.shape { + Some(shape) => shape.clone(), + None => detect_shape(&stroke.points)?, + }; + match shape { + RecognizedShape::Line { start, end } => Some((start, end)), + _ => None, + } +} + /// Führt die automatische Erkennung für einen Strich aus. pub fn detect_shape(points: &[StylusSample]) -> Option { if points.len() < 2 { @@ -2674,6 +2745,45 @@ mod tests { } } + #[test] + fn test_axis_cross_from_two_lines() { + let horizontal = (Vec2::new(100.0, 305.0), Vec2::new(400.0, 300.0)); + let vertical = (Vec2::new(252.0, 450.0), Vec2::new(248.0, 150.0)); + for (a, b) in [(horizontal, vertical), (vertical, horizontal)] { + let Some(RecognizedShape::CoordinateSystem { + origin, + x_axis_end, + y_axis_end, + with_arrows: true, + negative_ends: Some([x_start, y_start]), + }) = axis_cross_from_lines(a, b) + else { + panic!("kein Achsenkreuz"); + }; + assert_eq!(origin, Vec2::new(250.0, 302.5)); + assert_eq!(x_axis_end, Vec2::new(400.0, 302.5)); + assert_eq!(x_start, Vec2::new(100.0, 302.5)); + assert_eq!(y_axis_end, Vec2::new(250.0, 150.0)); + assert_eq!(y_start, Vec2::new(250.0, 450.0)); + } + } + + #[test] + fn test_no_axis_cross_from_lines_that_do_not_cross() { + let horizontal = (Vec2::new(100.0, 300.0), Vec2::new(400.0, 300.0)); + // Senkrechte endet über der Waagerechten (L oder T statt Kreuz) + let above = (Vec2::new(250.0, 100.0), Vec2::new(250.0, 290.0)); + assert!(axis_cross_from_lines(horizontal, above).is_none()); + // Senkrechte schneidet ganz am Rand + let at_edge = (Vec2::new(110.0, 150.0), Vec2::new(110.0, 450.0)); + assert!(axis_cross_from_lines(horizontal, at_edge).is_none()); + // Zwei Waagerechte oder eine schräge Linie + let parallel = (Vec2::new(100.0, 350.0), Vec2::new(400.0, 350.0)); + assert!(axis_cross_from_lines(horizontal, parallel).is_none()); + let diagonal = (Vec2::new(150.0, 150.0), Vec2::new(350.0, 450.0)); + assert!(axis_cross_from_lines(horizontal, diagonal).is_none()); + } + #[test] fn test_detect_axis_cross_in_one_stroke() { let c = Vec2::new(800.0, 900.0); diff --git a/src/main.rs b/src/main.rs index fb77b14..a37dd37 100644 --- a/src/main.rs +++ b/src/main.rs @@ -287,6 +287,23 @@ fn start_pdf_export( }); } +/// Achsenkreuz aus der eingerasteten Geraden `shape` und dem vorigen Strich der aktuellen +/// Seite, falls sich beide kreuzen (Issue #20); mit der Id des vorigen Strichs +fn axis_cross_with_previous( + notebook: &Notebook, + shape: &RecognizedShape, +) -> Option<(u64, RecognizedShape)> { + let RecognizedShape::Line { start, end } = shape else { + return None; + }; + let previous = notebook.current_page().strokes.last()?; + let line = geometry::stroke_as_line(previous)?; + Some(( + previous.id, + geometry::axis_cross_from_lines(line, (*start, *end))?, + )) +} + /// Finalisiert einen Strich (mit Spline-Tessellierung, Ecken-Erkennung & RDP oder Auto-Snap) /// und fügt ihn in die aktuelle Seite des Notizbuchs ein. fn commit_stroke_to_notebook( @@ -298,6 +315,27 @@ fn commit_stroke_to_notebook( window: &Window, snapped_shape: Option, ) { + // Zweite Gerade über Kreuz mit dem vorigen Strich: beide werden zum Achsenkreuz; der + // vorige Strich wird ersetzt, ein Rückgängig stellt ihn wieder her + if let Some(shape) = &snapped_shape { + if let Some((previous_id, cross)) = axis_cross_with_previous(notebook, shape) { + let stroke_width = ui_state.pen_stroke_width_mm * ink_notes::document::UNITS_PER_MM; + canvas.cancel_stroke(); + let stroke = geometry::shape_to_stroke( + cross, + previous_id, + canvas.current_ink_color, + stroke_width, + ); + let page = notebook.current_page_mut(); + page.replace_stroke(previous_id, stroke); + canvas.sync_page(page); + autosave.mark_dirty(notebook, manifest); + window.request_redraw(); + return; + } + } + let new_id = notebook.next_stroke_id(); let stroke_opt = if let Some(shape) = snapped_shape { let stroke_width = ui_state.pen_stroke_width_mm * ink_notes::document::UNITS_PER_MM; @@ -1824,6 +1862,10 @@ fn main() -> Result<(), Box> { if let Some(shape) = geometry::detect_shape(&canvas.live_samples) { stroke_already_snapped = true; stroke_snapped_shape = Some(shape.clone()); + // Kreuzt die Gerade den vorigen Strich, zeigt die Vorschau schon das + // Achsenkreuz (übernommen wird es beim Absetzen) + let shape = axis_cross_with_previous(¬ebook, &shape) + .map_or(shape, |(_, cross)| cross); let width_units = ui_state.pen_stroke_width_mm * ink_notes::document::UNITS_PER_MM; let (samples, vertices) = geometry::shape_to_mesh(&shape, width_units, canvas.current_ink_color); canvas.set_live_shape_mesh(samples, vertices);